From 30aa6767244de20c7eaa2fab12b9e09e18fa1329 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Thu, 20 Feb 2025 10:04:48 +0100 Subject: [PATCH 01/11] Elasticsearch: Invalid URL and unsupported protocol should be downstream errors (#100886) * Invalid URL and unsupported protocol should be downstream errors * Fix lint * Change from errors.Is to errors.As --- pkg/tsdb/elasticsearch/client/client.go | 2 +- pkg/tsdb/elasticsearch/data_query.go | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/pkg/tsdb/elasticsearch/client/client.go b/pkg/tsdb/elasticsearch/client/client.go index 73d03dcba7b..62f9ba2ee29 100644 --- a/pkg/tsdb/elasticsearch/client/client.go +++ b/pkg/tsdb/elasticsearch/client/client.go @@ -135,7 +135,7 @@ func (c *baseClientImpl) executeRequest(method, uriPath, uriQuery string, body [ c.logger.Debug("Sending request to Elasticsearch", "url", c.ds.URL) u, err := url.Parse(c.ds.URL) if err != nil { - return nil, err + return nil, backend.DownstreamError(fmt.Errorf("URL could not be parsed: %w", err)) } u.Path = path.Join(u.Path, uriPath) u.RawQuery = uriQuery diff --git a/pkg/tsdb/elasticsearch/data_query.go b/pkg/tsdb/elasticsearch/data_query.go index 79a9f5adb38..1c48b6e1326 100644 --- a/pkg/tsdb/elasticsearch/data_query.go +++ b/pkg/tsdb/elasticsearch/data_query.go @@ -3,10 +3,13 @@ package elasticsearch import ( "context" "encoding/json" + "errors" "fmt" + "net/url" "regexp" "slices" "strconv" + "strings" "time" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -82,6 +85,13 @@ func (e *elasticsearchDataQuery) execute() (*backend.QueryDataResponse, error) { if backend.IsDownstreamHTTPError(err) { err = backend.DownstreamError(err) } + var urlErr *url.Error + if errors.As(err, &urlErr) { + // Unsupported protocol scheme is a common error when the URL is not valid and should be treated as a downstream error + if urlErr.Err != nil && strings.HasPrefix(urlErr.Err.Error(), "unsupported protocol scheme") { + err = backend.DownstreamError(err) + } + } response.Responses[e.dataQueries[0].RefID] = backend.ErrorResponseWithErrorSource(err) return response, nil } From 4dfb21ecdddc7dca54fc1f564efd850fbf9d8aa9 Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Thu, 20 Feb 2025 10:17:05 +0100 Subject: [PATCH 02/11] Dynamic dashboards: Implement cloneLayout for responsive grid (#100855) * implement clone layout for responsive grid * Add tests * Fix lint * Make sure we base new keys on old keys * Start tests from panel 1 instead of 0 --- .../DefaultGridLayoutManager.test.tsx | 16 ++++++ .../ResponsiveGridLayoutManager.test.ts | 53 +++++++++++++++++++ .../ResponsiveGridLayoutManager.tsx | 30 ++++++++++- .../scene/layout-rows/RowsLayoutManager.tsx | 4 ++ .../scene/layout-tabs/TabsLayoutManager.tsx | 4 ++ .../scene/types/DashboardLayoutManager.ts | 2 +- 6 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.test.ts diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx index fdb9dd9fc38..c2e3bb77691 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx @@ -210,6 +210,22 @@ describe('DefaultGridLayoutManager', () => { expect(gridRow.state.children.length).toBe(3); }); + + it('Should clone the layout correctly', () => { + const { manager } = setup(); + const clone = manager.cloneLayout('foo', true) as DefaultGridLayoutManager; + const panelA = findVizPanelByKey(clone, 'foo/grid-item-0/panel-0'); + expect(panelA?.state.title).toBe('Panel A'); + + const panelB = findVizPanelByKey(clone, 'foo/grid-item-1/panel-1'); + expect(panelB?.state.title).toBe('Panel B'); + + const panelC = findVizPanelByKey(clone, 'foo/panel-2/grid-item-3/panel-3'); + expect(panelC?.state.title).toBe('Panel C'); + + const panelD = findVizPanelByKey(clone, 'foo/panel-2/grid-item-4/panel-4'); + expect(panelD?.state.title).toBe('Panel D'); + }); }); }); diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.test.ts b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.test.ts new file mode 100644 index 00000000000..ff43c9b1e8f --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.test.ts @@ -0,0 +1,53 @@ +import { SceneCSSGridLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes'; + +import { findVizPanelByKey } from '../../utils/utils'; +import { DashboardScene } from '../DashboardScene'; + +import { ResponsiveGridItem } from './ResponsiveGridItem'; +import { ResponsiveGridLayoutManager } from './ResponsiveGridLayoutManager'; + +describe('ResponsiveGridLayoutManager', () => { + it('Should clone the layout', () => { + const { manager } = setup(); + const clone = manager.cloneLayout('foo', true) as ResponsiveGridLayoutManager; + + expect(clone).not.toBe(manager); + expect(clone.state.layout).not.toBe(manager.state.layout); + expect(clone.state.layout.state.children).not.toBe(manager.state.layout.state.children); + expect(clone.state.layout.state.children.length).toBe(manager.state.layout.state.children.length); + + const panelA = findVizPanelByKey(clone, 'foo/grid-item-1/panel-1'); + expect(panelA?.state.title).toBe('Panel A'); + + const panelB = findVizPanelByKey(clone, 'foo/grid-item-2/panel-2'); + expect(panelB?.state.title).toBe('Panel B'); + }); +}); + +function setup() { + const gridItems = [ + new ResponsiveGridItem({ + key: 'grid-item-1', + body: new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'table', + $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), + }), + }), + new ResponsiveGridItem({ + key: 'grid-item-2', + body: new VizPanel({ + title: 'Panel B', + key: 'panel-2', + pluginId: 'table', + }), + }), + ]; + + const manager = new ResponsiveGridLayoutManager({ layout: new SceneCSSGridLayout({ children: gridItems }) }); + + new DashboardScene({ body: manager }); + + return { manager }; +} diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx index bde10fa3374..8c3505038c2 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx @@ -2,8 +2,14 @@ import { SceneComponentProps, SceneCSSGridLayout, SceneObjectBase, SceneObjectSt import { t } from 'app/core/internationalization'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; +import { joinCloneKeys } from '../../utils/clone'; import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph'; -import { getDashboardSceneFor, getGridItemKeyForPanelId, getVizPanelKeyForPanelId } from '../../utils/utils'; +import { + getDashboardSceneFor, + getGridItemKeyForPanelId, + getPanelIdForVizPanel, + getVizPanelKeyForPanelId, +} from '../../utils/utils'; import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager'; import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; @@ -115,6 +121,28 @@ export class ResponsiveGridLayoutManager return false; } + public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager { + return this.clone({ + layout: this.state.layout.clone({ + children: this.state.layout.state.children.map((gridItem) => { + if (gridItem instanceof ResponsiveGridItem) { + // Get the original panel ID from the gridItem's key + const panelId = getPanelIdForVizPanel(gridItem.state.body); + const gridItemKey = joinCloneKeys(ancestorKey, getGridItemKeyForPanelId(panelId)); + + return gridItem.clone({ + key: gridItemKey, + body: gridItem.state.body.clone({ + key: joinCloneKeys(gridItemKey, getVizPanelKeyForPanelId(panelId)), + }), + }); + } + throw new Error('Unexpected child type'); + }), + }), + }); + } + public addNewRow() { const shouldAddRow = this.hasVizPanels(); const rowsLayout = RowsLayoutManager.createFromLayout(this); diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx index fd3150af3e8..54fb186937d 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -77,6 +77,10 @@ export class RowsLayoutManager extends SceneObjectBase i return false; } + public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager { + throw new Error('Method not implemented.'); + } + public addNewRow() { this.setState({ rows: [...this.state.rows, new RowItem()] }); } diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx index 62279b315c1..daa86286dae 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx @@ -47,6 +47,10 @@ export class TabsLayoutManager extends SceneObjectBase i return panels; } + public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager { + throw new Error('Method not implemented.'); + } + public hasVizPanels(): boolean { for (const tab of this.state.tabs) { if (tab.getLayout().hasVizPanels()) { diff --git a/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts b/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts index 18fc2c74064..6041799d8db 100644 --- a/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts +++ b/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts @@ -80,7 +80,7 @@ export interface DashboardLayoutManager extends SceneObject { * @param ancestorKey * @param isSource */ - cloneLayout?(ancestorKey: string, isSource: boolean): DashboardLayoutManager; + cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager; } export interface LayoutManagerSerializer { From c654a580f7d40acbf3dcb67cfc4e4d9cc40f5f61 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 20 Feb 2025 12:17:52 +0300 Subject: [PATCH 03/11] Authz: Add builtin provisioning access policy (#101000) --- pkg/apimachinery/identity/context.go | 39 ++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/pkg/apimachinery/identity/context.go b/pkg/apimachinery/identity/context.go index 81a7f81de6c..7600f000e7e 100644 --- a/pkg/apimachinery/identity/context.go +++ b/pkg/apimachinery/identity/context.go @@ -31,6 +31,7 @@ func checkNilRequester(r Requester) bool { } const serviceName = "service" +const serviceNameForProvisioning = "provisioning" // WithServiceIdentity sets an identity representing the service itself in provided org and store it in context. // This is useful for background tasks that has to communicate with unfied storage. It also returns a Requester with @@ -53,6 +54,30 @@ func WithServiceIdentity(ctx context.Context, orgID int64) (context.Context, Req return WithRequester(ctx, r), r } +func WithProvisioningIdentitiy(ctx context.Context, namespace string) (context.Context, Requester, error) { + ns, err := types.ParseNamespace(namespace) + if err != nil { + return nil, nil, err + } + + r := &StaticRequester{ + Type: types.TypeAccessPolicy, + Name: serviceNameForProvisioning, + UserUID: serviceNameForProvisioning, + AuthID: serviceNameForProvisioning, + Login: serviceNameForProvisioning, + OrgRole: RoleAdmin, + IsGrafanaAdmin: true, + Namespace: namespace, + OrgID: ns.OrgID, + Permissions: map[int64]map[string][]string{ + ns.OrgID: serviceIdentityPermissions, + }, + } + + return WithRequester(ctx, r), r, nil +} + // WithServiceIdentityContext sets an identity representing the service itself in context. func WithServiceIdentityContext(ctx context.Context, orgID int64) context.Context { ctx, _ = WithServiceIdentity(ctx, orgID) @@ -79,19 +104,29 @@ var serviceIdentityPermissions = getWildcardPermissions( "folders:read", "folders:write", "folders:create", + "folders:delete", "dashboards:read", "dashboards:write", "dashboards:create", "datasources:query", "datasources:read", + "datasources:delete", "alert.provisioning:write", "alert.provisioning.secrets:read", + "users:read", // accesscontrol.ActionUsersRead, + "org.users:read", // accesscontrol.ActionOrgUsersRead, + "teams:read", // accesscontrol.ActionTeamsRead, ) func IsServiceIdentity(ctx context.Context) bool { - ident, err := GetRequester(ctx) + ident, ok := types.AuthInfoFrom(ctx) + if !ok { + return false + } + t, uid, err := types.ParseTypeID(ident.GetUID()) if err != nil { return false } - return ident.GetUID() == types.NewTypeID(types.TypeAccessPolicy, serviceName) + + return t == types.TypeAccessPolicy && (uid == serviceName || uid == serviceNameForProvisioning) } From 9eda0f44ac6e69db35f038d781d1b3487398cf70 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Thu, 20 Feb 2025 10:23:53 +0100 Subject: [PATCH 04/11] Update `ErrRequestErrorBase` from `[plugin.downstreamError]` to `[plugin.requestFailureError]` (#100965) * Update ErrRequestErrorBase to make it more clear for developers * Trigger build * requestError -> requestFailureError * Fix test * Update pkg/plugins/backendplugin/grpcplugin/client_v2.go Co-authored-by: Will Browne --------- Co-authored-by: Will Browne --- pkg/api/ds_query_test.go | 2 +- pkg/apimachinery/errutil/errors.go | 2 +- pkg/plugins/backendplugin/grpcplugin/client_v2.go | 7 +++---- pkg/plugins/errors.go | 6 +++--- pkg/plugins/manager/client/client.go | 6 +++--- pkg/plugins/manager/client/client_test.go | 2 +- 6 files changed, 12 insertions(+), 13 deletions(-) diff --git a/pkg/api/ds_query_test.go b/pkg/api/ds_query_test.go index 8744b5fbf6a..60d6d3a2a47 100644 --- a/pkg/api/ds_query_test.go +++ b/pkg/api/ds_query_test.go @@ -249,7 +249,7 @@ func TestDataSourceQueryError(t *testing.T) { expectedStatus: errutil.StatusInternal.HTTPStatus(), expectedBody: body{ Message: "An error occurred within the plugin", - MessageId: "plugin.downstreamError", + MessageId: "plugin.requestFailureError", StatusCode: 500, }, }, diff --git a/pkg/apimachinery/errutil/errors.go b/pkg/apimachinery/errutil/errors.go index 00bf30ff628..e8bc0e4b0f2 100644 --- a/pkg/apimachinery/errutil/errors.go +++ b/pkg/apimachinery/errutil/errors.go @@ -114,7 +114,7 @@ func ValidationFailed(msgID string, opts ...BaseOpt) Base { // msgID should be structured as component.errorBrief, for example // // sqleng.connectionError -// plugin.downstreamError +// plugin.requestFailureError func Internal(msgID string, opts ...BaseOpt) Base { return NewBase(StatusInternal, msgID, opts...) } diff --git a/pkg/plugins/backendplugin/grpcplugin/client_v2.go b/pkg/plugins/backendplugin/grpcplugin/client_v2.go index 2bcdaa76a51..ade867a2ace 100644 --- a/pkg/plugins/backendplugin/grpcplugin/client_v2.go +++ b/pkg/plugins/backendplugin/grpcplugin/client_v2.go @@ -356,15 +356,14 @@ func handleGrpcStatusError(ctx context.Context, errorSource errstatus.Source, er if innerErr != nil { logger.Error("Could not set downstream error source", "error", innerErr) } - return plugins.ErrPluginDownstreamErrorBase.Errorf("%v", err) + return plugins.ErrPluginRequestFailureErrorBase.Errorf("%v", err) case backend.ErrorSourcePlugin: errorSourceErr := backend.WithErrorSource(ctx, backend.ErrorSourcePlugin) if errorSourceErr != nil { logger.Error("Could not set plugin error source", "error", errorSourceErr) } - // a downstream error is returned here as plugin errors are considered as downstream errors in the - // context of the Grafana server. - return plugins.ErrPluginDownstreamErrorBase.Errorf("%v", err) + // plugin request has failed after being sent from the Grafana server + return plugins.ErrPluginRequestFailureErrorBase.Errorf("%v", err) } return fmt.Errorf("%v: %w", "Failed to query data", err) } diff --git a/pkg/plugins/errors.go b/pkg/plugins/errors.go index f65beac7d2d..1ab785ff7a3 100644 --- a/pkg/plugins/errors.go +++ b/pkg/plugins/errors.go @@ -24,9 +24,9 @@ var ( errutil.WithPublicMessage("Plugin health check failed"), errutil.WithDownstream()) - // ErrPluginDownstreamErrorBase error returned when a plugin request fails. - // Exposed as a base error to wrap it with plugin downstream errors. - ErrPluginDownstreamErrorBase = errutil.Internal("plugin.downstreamError", + // ErrPluginRequestFailureErrorBase error returned when a plugin request fails. + // Exposed as a base error to wrap it with plugin request errors. + ErrPluginRequestFailureErrorBase = errutil.Internal("plugin.requestFailureError", errutil.WithPublicMessage("An error occurred within the plugin"), errutil.WithDownstream()) diff --git a/pkg/plugins/manager/client/client.go b/pkg/plugins/manager/client/client.go index 7a972fb617e..7ab5d6a60ff 100644 --- a/pkg/plugins/manager/client/client.go +++ b/pkg/plugins/manager/client/client.go @@ -68,7 +68,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) return nil, plugins.ErrPluginRequestCanceledErrorBase.Errorf("client: query data request canceled: %w", err) } - return nil, plugins.ErrPluginDownstreamErrorBase.Errorf("client: failed to query data: %w", err) + return nil, plugins.ErrPluginRequestFailureErrorBase.Errorf("client: failed to query data: %w", err) } for refID, res := range resp.Responses { @@ -127,7 +127,7 @@ func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceReq return plugins.ErrPluginRequestCanceledErrorBase.Errorf("client: call resource request canceled: %w", err) } - return plugins.ErrPluginDownstreamErrorBase.Errorf("client: failed to call resources: %w", err) + return plugins.ErrPluginRequestFailureErrorBase.Errorf("client: failed to call resources: %w", err) } return nil @@ -149,7 +149,7 @@ func (s *Service) CollectMetrics(ctx context.Context, req *backend.CollectMetric return nil, plugins.ErrPluginRequestCanceledErrorBase.Errorf("client: collect metrics request canceled: %w", err) } - return nil, plugins.ErrPluginDownstreamErrorBase.Errorf("client: failed to collect metrics: %w", err) + return nil, plugins.ErrPluginRequestFailureErrorBase.Errorf("client: failed to collect metrics: %w", err) } return resp, nil diff --git a/pkg/plugins/manager/client/client_test.go b/pkg/plugins/manager/client/client_test.go index 420754059d2..f45761c3649 100644 --- a/pkg/plugins/manager/client/client_test.go +++ b/pkg/plugins/manager/client/client_test.go @@ -38,7 +38,7 @@ func TestQueryData(t *testing.T) { }, { err: errors.New("surprise surprise"), - expectedError: plugins.ErrPluginDownstreamErrorBase, + expectedError: plugins.ErrPluginRequestFailureErrorBase, }, { err: context.Canceled, From 6ccc56814c4ac9430e2251e6f2b2b6ee551908ab Mon Sep 17 00:00:00 2001 From: Igor Suleymanov Date: Thu, 20 Feb 2025 11:39:12 +0200 Subject: [PATCH 05/11] Add resource annotations for storing manager properties (#99683) What is this feature? This change adds properties and known annotations to store them in for recording resource manager information, such as: The type (kind) of the manager (ex. Terraform / kubectl / etc.) The identity of the manager (ex. grafana/terraform-provider-grafana) Whether the managers allows the resource to be edited by others. Whether a resource is temporarily excluded from the manager's control. These annotations are inspired by Kubernetes field management API (https://kubernetes.io/docs/reference/using-api/server-side-apply/#field-management) and known Kubernetes annotations (https://kubernetes.io/docs/reference/labels-annotations-taints/#app-kubernetes-io-managed-by). It also adds annotations for storing information about the source of a provisioned resource, such as path, checksum & timestamp. Why do we need this feature? To make it possible to mark resources as managed by specific managers, modifying how these resources appear in the UI and are treated in the backend APIs. For example, we'd like to make managed resources read-only, or show specific docs / workflows based on the tool which is used to manage resources and so on. The identity is required for ensuring that managers of the same kind can still be told apart. Who is this feature for? For as-code practitioners and API users. --------- Signed-off-by: Igor Suleymanov Co-authored-by: Ryan McKinley --- pkg/apimachinery/utils/manager.go | 67 ++++++++++++++ pkg/apimachinery/utils/meta.go | 130 ++++++++++++++++++++++++++++ pkg/apimachinery/utils/meta_test.go | 130 ++++++++++++++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 pkg/apimachinery/utils/manager.go diff --git a/pkg/apimachinery/utils/manager.go b/pkg/apimachinery/utils/manager.go new file mode 100644 index 00000000000..76add8722c7 --- /dev/null +++ b/pkg/apimachinery/utils/manager.go @@ -0,0 +1,67 @@ +package utils + +import "time" + +// ManagerProperties is used to identify the manager of the resource. +type ManagerProperties struct { + // The kind of manager, which is responsible for managing the resource. + // Examples include "git", "terraform", "kubectl", etc. + Kind ManagerKind + + // The identity of the manager, which refers to a specific instance of the manager. + // The format & the value depends on the manager kind. + Identity string + + // AllowsEdits indicates whether the manager allows edits to the resource. + // If set to true, it means that other requesters can edit the resource. + AllowsEdits bool + + // Suspended indicates whether the manager is suspended. + // If set to true, then the manager skip updates to the resource. + Suspended bool +} + +// ManagerKind is the type of manager, which is responsible for managing the resource. +// It can be a user or a tool or a generic API client. +type ManagerKind string + +// Known values for ManagerKind. +const ( + ManagerKindUnknown ManagerKind = "" + ManagerKindRepo ManagerKind = "repo" + ManagerKindTerraform ManagerKind = "terraform" + ManagerKindKubectl ManagerKind = "kubectl" +) + +// ParseManagerKindString parses a string into a ManagerKind. +// It returns the ManagerKind and a boolean indicating whether the string was a valid ManagerKind. +// For unknown values, it returns ManagerKindUnknown and false. +func ParseManagerKindString(v string) ManagerKind { + switch v { + case string(ManagerKindRepo): + return ManagerKindRepo + case string(ManagerKindTerraform): + return ManagerKindTerraform + case string(ManagerKindKubectl): + return ManagerKindKubectl + default: + return ManagerKindUnknown + } +} + +// SourceProperties is used to identify the source of a provisioned resource. +// It is used by managers for reconciling data from a source to Grafana. +// Not all managers use these properties, some (like Terraform) don't have a concept of a source. +type SourceProperties struct { + // The path to the source of the resource. + // Can be a file path, a URL, etc. + Path string + + // The checksum of the source of the resource. + // An example could be a git commit hash. + Checksum string + + // The timestamp of the source of the resource. + // An example could be the file modification time. + Timestamp time.Time +} diff --git a/pkg/apimachinery/utils/meta.go b/pkg/apimachinery/utils/meta.go index 5b2978533bb..116fc4f37d9 100644 --- a/pkg/apimachinery/utils/meta.go +++ b/pkg/apimachinery/utils/meta.go @@ -46,6 +46,19 @@ const AnnoKeyRepoPath = "grafana.app/repoPath" const AnnoKeyRepoHash = "grafana.app/repoHash" const AnnoKeyRepoTimestamp = "grafana.app/repoTimestamp" +// Annotations used to store manager properties + +const AnnoKeyManagerKind = "grafana.app/managedBy" +const AnnoKeyManagerIdentity = "grafana.app/managerId" +const AnnoKeyManagerAllowsEdits = "grafana.app/managerAllowsEdits" +const AnnoKeyManagerSuspended = "grafana.app/managerSuspended" + +// Annotations used to store source properties + +const AnnoKeySourcePath = "grafana.app/sourcePath" +const AnnoKeySourceHash = "grafana.app/sourceHash" +const AnnoKeySourceTimestamp = "grafana.app/sourceTimestamp" + // LabelKeyDeprecatedInternalID gives the deprecated internal ID of a resource // Deprecated: will be removed in grafana 13 const LabelKeyDeprecatedInternalID = "grafana.app/deprecatedInternalID" @@ -138,6 +151,22 @@ type GrafanaMetaAccessor interface { // * title // and return an empty string if nothing was found FindTitle(defaultTitle string) string + + // GetManagerProperties returns the identity of the tool, + // which is responsible for managing the resource. + // + // If the identity is not known, the second return value will be false. + GetManagerProperties() (ManagerProperties, bool) + + // SetManagerProperties sets the identity of the tool, + // which is responsible for managing the resource. + SetManagerProperties(ManagerProperties) + + // GetSourceProperties returns the source properties of the resource. + GetSourceProperties() (SourceProperties, bool) + + // SetSourceProperties sets the source properties of the resource. + SetSourceProperties(SourceProperties) } var _ GrafanaMetaAccessor = (*grafanaMetaAccessor)(nil) @@ -718,6 +747,107 @@ func (m *grafanaMetaAccessor) FindTitle(defaultTitle string) string { return defaultTitle } +func (m *grafanaMetaAccessor) GetManagerProperties() (ManagerProperties, bool) { + res := ManagerProperties{ + Identity: "", + Kind: ManagerKindUnknown, + AllowsEdits: true, + Suspended: false, + } + + annot := m.obj.GetAnnotations() + + id, ok := annot[AnnoKeyManagerIdentity] + if !ok || id == "" { + // If the identity is not set, we should ignore the other annotations and return the default values. + // + // This is to prevent inadvertently marking resources as managed, + // since that can potentially block updates from other sources. + return res, false + } + res.Identity = id + + if v, ok := annot[AnnoKeyManagerKind]; ok { + res.Kind = ParseManagerKindString(v) + } + + if v, ok := annot[AnnoKeyManagerAllowsEdits]; ok { + res.AllowsEdits = v == "true" + } + + if v, ok := annot[AnnoKeyManagerSuspended]; ok { + res.Suspended = v == "true" + } + + return res, true +} + +func (m *grafanaMetaAccessor) SetManagerProperties(v ManagerProperties) { + annot := m.obj.GetAnnotations() + if annot == nil { + annot = make(map[string]string, 4) + } + + annot[AnnoKeyManagerIdentity] = v.Identity + annot[AnnoKeyManagerKind] = string(v.Kind) + annot[AnnoKeyManagerAllowsEdits] = strconv.FormatBool(v.AllowsEdits) + annot[AnnoKeyManagerSuspended] = strconv.FormatBool(v.Suspended) + + m.obj.SetAnnotations(annot) +} + +func (m *grafanaMetaAccessor) GetSourceProperties() (SourceProperties, bool) { + var ( + res SourceProperties + found bool + ) + + annot := m.obj.GetAnnotations() + if annot == nil { + return res, false + } + + if path, ok := annot[AnnoKeySourcePath]; ok && path != "" { + res.Path = path + found = true + } + + if hash, ok := annot[AnnoKeySourceHash]; ok && hash != "" { + res.Checksum = hash + found = true + } + + if timestamp, ok := annot[AnnoKeySourceTimestamp]; ok && timestamp != "" { + if t, err := time.Parse(time.RFC3339, timestamp); err == nil { + res.Timestamp = t + found = true + } + } + + return res, found +} + +func (m *grafanaMetaAccessor) SetSourceProperties(v SourceProperties) { + annot := m.obj.GetAnnotations() + if annot == nil { + annot = make(map[string]string, 3) + } + + if v.Path != "" { + annot[AnnoKeySourcePath] = v.Path + } + + if v.Checksum != "" { + annot[AnnoKeySourceHash] = v.Checksum + } + + if !v.Timestamp.IsZero() { + annot[AnnoKeySourceTimestamp] = v.Timestamp.Format(time.RFC3339) + } + + m.obj.SetAnnotations(annot) +} + type BlobInfo struct { UID string `json:"uid"` Size int64 `json:"size,omitempty"` diff --git a/pkg/apimachinery/utils/meta_test.go b/pkg/apimachinery/utils/meta_test.go index 12d12cc06b5..1796125048f 100644 --- a/pkg/apimachinery/utils/meta_test.go +++ b/pkg/apimachinery/utils/meta_test.go @@ -3,6 +3,7 @@ package utils_test import ( "encoding/json" "testing" + "time" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -433,6 +434,135 @@ func TestMetaAccessor(t *testing.T) { require.Equal(t, obj2.Spec, spec) require.NoError(t, err) }) + + t.Run("ManagerProperties", func(t *testing.T) { + tests := []struct { + name string + setProperties *utils.ManagerProperties + wantProperties utils.ManagerProperties + wantOK bool + }{ + { + name: "get default values", + wantProperties: utils.ManagerProperties{ + Identity: "", + Kind: utils.ManagerKindUnknown, + AllowsEdits: true, + Suspended: false, + }, + wantOK: false, + }, + { + name: "set and get valid values", + setProperties: &utils.ManagerProperties{ + Identity: "identity", + Kind: utils.ManagerKindTerraform, + AllowsEdits: false, + Suspended: false, + }, + wantProperties: utils.ManagerProperties{ + Identity: "identity", + Kind: utils.ManagerKindTerraform, + AllowsEdits: false, + Suspended: false, + }, + wantOK: true, + }, + { + name: "set empty identity returns default values", + setProperties: &utils.ManagerProperties{ + Identity: "", + Kind: utils.ManagerKindRepo, + AllowsEdits: false, + Suspended: false, + }, + wantProperties: utils.ManagerProperties{ + Identity: "", + Kind: utils.ManagerKindUnknown, + AllowsEdits: true, + Suspended: false, + }, + wantOK: false, + }, + { + name: "invalid kind falls back to generic kind", + setProperties: &utils.ManagerProperties{ + Identity: "identity", + Kind: utils.ManagerKind("invalid"), + AllowsEdits: false, + Suspended: true, + }, + wantProperties: utils.ManagerProperties{ + Identity: "identity", + Kind: utils.ManagerKindUnknown, + AllowsEdits: false, + Suspended: true, + }, + wantOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := &TestResource2{} + meta, err := utils.MetaAccessor(res) + require.NoError(t, err) + + if tt.setProperties != nil { + meta.SetManagerProperties(*tt.setProperties) + } + + mp, ok := meta.GetManagerProperties() + require.Equal(t, tt.wantOK, ok) + require.Equal(t, tt.wantProperties, mp) + }) + } + }) + + t.Run("SourceProperties", func(t *testing.T) { + tests := []struct { + name string + setProperties *utils.SourceProperties + wantProperties utils.SourceProperties + wantOK bool + }{ + { + name: "get default values", + wantProperties: utils.SourceProperties{}, + wantOK: false, + }, + { + name: "set and get valid values", + setProperties: &utils.SourceProperties{ + Path: "path", + Checksum: "hash", + Timestamp: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), + }, + wantProperties: utils.SourceProperties{ + Path: "path", + Checksum: "hash", + Timestamp: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), + }, + wantOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := &TestResource2{} + meta, err := utils.MetaAccessor(res) + require.NoError(t, err) + + if tt.setProperties != nil { + meta.SetSourceProperties(*tt.setProperties) + } + + sp, ok := meta.GetSourceProperties() + require.Equal(t, tt.wantProperties, sp) + require.Equal(t, tt.wantOK, ok) + }) + } + }) } func asJSON(v any, pretty bool) string { From e2149e35e4378392088b4b34ab265c31541e74f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irene=20Rodr=C3=ADguez?= Date: Thu, 20 Feb 2025 10:40:39 +0100 Subject: [PATCH 06/11] fix broken link (#100967) --- .../teamlbac/configure-teamlbac-for-prometheus/index.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-prometheus/index.md b/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-prometheus/index.md index 1cc06175900..ab2c3f78656 100644 --- a/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-prometheus/index.md +++ b/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-prometheus/index.md @@ -18,7 +18,7 @@ weight: 250 LBAC for data sources is available in private preview on Grafana Cloud for Prometheus created with basic authentication. Prometheus data sources for LBAC for data sources can only be created, provisioning is currently not available. -You cannot configure LBAC rules for Grafana-provisioned data sources from the UI. Alternatively, you can replicate the setting of the provisioned data source in a new data source as described in [LBAC Configuration for New Prometheus Data Source](https://grafana.com/docs/grafana/latest/administration/data-source-management/teamlbac/configure-teamlbac-for-Prometheus/#task-1-lbac-configuration-for-new-Prometheus-data-source) and then add the LBAC configuration to the new data source. +You cannot configure LBAC rules for Grafana-provisioned data sources from the UI. Alternatively, you can replicate the setting of the provisioned data source in a new data source as described in [LBAC Configuration for New Prometheus Data Source](#task-1-lbac-configuration-for-new-prometheus-data-source) and then add the LBAC configuration to the new data source. ## Before you begin @@ -31,7 +31,7 @@ To be able to use LBAC for data sources rules, you need to enable the feature to We recommend that you remove all permissions for roles and teams that are not required to access the data source. This will help to ensure that only the required teams have access to the data source. The recommended permissions are `Admin` permission and only add the teams `Query` permissions that you want to add LBAC for data sources rules for. -## Task 1: LBAC Configuration for New Prometheus Data Source +## Task 1: LBAC Configuration for new Prometheus data source 1. Access Prometheus data sources details for your stack through grafana.com 1. Copy Prometheus details and create a CAP @@ -53,7 +53,7 @@ For more information on how to setup LBAC for data sources rules for a Prometheu LBAC for data sources is available in Grafana Enterprise for Prometheus created with basic authentication. Prometheus data sources for LBAC for data sources can only be created. -You cannot configure LBAC rules for Grafana-provisioned data sources from the UI. Alternatively, you can replicate the setting of the provisioned data source in a new data source as described in [LBAC Configuration for New Prometheus Data Source](https://grafana.com/docs/grafana/latest/administration/data-source-management/teamlbac/configure-teamlbac-for-Prometheus/#task-1-lbac-configuration-for-new-Prometheus-data-source) and then add the LBAC configuration to the new data source. +You cannot configure LBAC rules for Grafana-provisioned data sources from the UI. Alternatively, you can replicate the setting of the provisioned data source in a new data source as described in [LBAC Configuration for new Prometheus data source](https://grafana.com/docs/grafana//administration/data-source-management/teamlbac/configure-teamlbac-for-prometheus/#task-1-lbac-configuration-for-new-prometheus-data-source) and then add the LBAC configuration to the new data source. ## Before you begin @@ -79,7 +79,7 @@ We recommend that you remove all permissions for roles and teams that are not re - Ensure the CAP does not include `labels` rules. - Store the `token` to be used as password for authentication. -## Task 1: LBAC Configuration for New Prometheus Data Source +## Task 1: LBAC Configuration for new Prometheus data source 1. Create a new Prometheus data source - In Grafana, proceed to add a new data source and select Prometheus as the type. From c9da1ec8501f0c8e81dfb3af76500cba0734e257 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Thu, 20 Feb 2025 09:46:34 +0000 Subject: [PATCH 07/11] Chore: Update tests and remove `MockDataSourceSrv` (#97748) --- .betterer.results | 5 +- public/app/features/alerting/unified/mocks.ts | 87 +------------------ .../alerting/unified/mocks/server/db.ts | 6 +- .../correlations/CorrelationsPage.test.tsx | 60 ++++++------- .../Forms/QueryEditorField.test.tsx | 4 +- .../PanelDataAlertingTab.test.tsx | 64 +++++++------- .../panel-edit/PanelEditor.test.ts | 17 ++-- .../AnnotationsSettings.test.tsx | 43 ++++----- .../dashboard/state/DashboardMigrator.test.ts | 6 +- .../features/templating/template_srv.test.ts | 23 +++-- public/app/features/trails/DataTrail.test.tsx | 16 ++-- .../trails/TrailStore/TrailStore.test.ts | 16 ++-- public/app/features/trails/otel/utils.test.ts | 32 ++++--- .../dashboard/DashboardQueryEditor.test.tsx | 10 +-- 14 files changed, 148 insertions(+), 241 deletions(-) diff --git a/.betterer.results b/.betterer.results index 60612fa6fa7..6ca30a27198 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2865,10 +2865,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"], [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"] + [0, 0, 0, "Unexpected any. Specify a different type.", "5"] ], "public/app/features/alerting/unified/plugins/PluginOriginBadge.tsx:5381": [ [0, 0, 0, "\'@grafana/ui/src/components/Icon/utils\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 278178b395e..353a9531b9d 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -1,28 +1,19 @@ import { produce } from 'immer'; import { isEmpty, pick } from 'lodash'; -import { Observable } from 'rxjs'; import { - DataQuery, - DataQueryRequest, - DataQueryResponse, - DataSourceApi, DataSourceInstanceSettings, DataSourceJsonData, DataSourcePluginMeta, - DataSourceRef, PluginExtensionLink, PluginExtensionTypes, ReducerID, - ScopedVars, - TestDataSourceResponse, } from '@grafana/data'; -import { DataSourceSrv, GetDataSourceListFilters, RuntimeDataSourceRegistration, config } from '@grafana/runtime'; +import { config } from '@grafana/runtime'; import { defaultDashboard } from '@grafana/schema'; import { contextSrv } from 'app/core/services/context_srv'; import { MOCK_GRAFANA_ALERT_RULE_TITLE } from 'app/features/alerting/unified/mocks/server/handlers/grafanaRuler'; import { ExpressionQuery, ExpressionQueryType, ReducerMode } from 'app/features/expressions/types'; -import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; import { AlertManagerCortexConfig, AlertState, @@ -400,82 +391,6 @@ export const mockReceiversState = (partial: Partial = {}): Recei }; }; -class MockDataSourceApi extends DataSourceApi { - constructor(instanceSettings: DataSourceInstanceSettings) { - super(instanceSettings); - } - - query(request: DataQueryRequest): Promise | Observable { - throw new Error('Method not implemented.'); - } - testDatasource(): Promise { - throw new Error('Method not implemented.'); - } -} - -/** @deprecated use `setupDatasources` instead */ -export class MockDataSourceSrv implements DataSourceSrv { - datasources: Record = {}; - // @ts-ignore - private settingsMapByName: Record = {}; - private settingsMapByUid: Record = {}; - private settingsMapById: Record = {}; - // @ts-ignore - private templateSrv = { - getVariables: () => [], - replace: (name: any) => name, - }; - - defaultName = ''; - - constructor(datasources: Record) { - this.datasources = {}; - this.settingsMapByName = Object.values(datasources).reduce>( - (acc, ds) => { - acc[ds.name] = ds; - return acc; - }, - {} - ); - - for (const dsSettings of Object.values(this.settingsMapByName)) { - this.settingsMapByUid[dsSettings.uid] = dsSettings; - this.settingsMapById[dsSettings.id] = dsSettings; - if (dsSettings.isDefault) { - this.defaultName = dsSettings.name; - } - this.datasources[dsSettings.uid] = new MockDataSourceApi(dsSettings); - } - } - - registerRuntimeDataSource(entry: RuntimeDataSourceRegistration): void {} - - get(name?: string | null | DataSourceRef, scopedVars?: ScopedVars): Promise { - return DatasourceSrv.prototype.get.call(this, name, scopedVars); - //return Promise.reject(new Error('not implemented')); - } - - /** - * Get a list of data sources - */ - getList(filters?: GetDataSourceListFilters): DataSourceInstanceSettings[] { - return DatasourceSrv.prototype.getList.call(this, filters); - } - - /** - * Get settings and plugin metadata by name or uid - */ - getInstanceSettings(nameOrUid: string | null | undefined): DataSourceInstanceSettings | undefined { - return DatasourceSrv.prototype.getInstanceSettings.call(this, nameOrUid); - } - - async loadDatasource(name: string): Promise> { - return DatasourceSrv.prototype.loadDatasource.call(this, name); - } - - reload() {} -} - export const mockGrafanaReceiver = ( type: string, overrides: Partial = {} diff --git a/public/app/features/alerting/unified/mocks/server/db.ts b/public/app/features/alerting/unified/mocks/server/db.ts index ba78b0be31a..be2e261ba2f 100644 --- a/public/app/features/alerting/unified/mocks/server/db.ts +++ b/public/app/features/alerting/unified/mocks/server/db.ts @@ -2,7 +2,7 @@ import { Factory } from 'fishery'; import { uniqueId } from 'lodash'; import { DataSourceInstanceSettings, PluginType } from '@grafana/data'; -import { config, setDataSourceSrv } from '@grafana/runtime'; +import { config } from '@grafana/runtime'; import { FolderDTO } from 'app/types'; import { PromAlertingRuleDTO, @@ -14,7 +14,7 @@ import { RulerRuleGroupDTO, } from 'app/types/unified-alerting-dto'; -import { MockDataSourceSrv } from '../../mocks'; +import { setupDataSources } from '../../testSetup/datasources'; import { DataSourceType } from '../../utils/datasource'; const prometheusRuleFactory = Factory.define(({ sequence }) => ({ @@ -62,7 +62,7 @@ const prometheusRuleGroupFactory = Factory.define(({ sequence const dataSourceFactory = Factory.define(({ sequence, params, afterBuild }) => { afterBuild((dataSource) => { config.datasources[dataSource.name] = dataSource; - setDataSourceSrv(new MockDataSourceSrv(config.datasources)); + setupDataSources(...Object.values(config.datasources)); }); const uid = params.uid ?? `mock-ds-${sequence}`; diff --git a/public/app/features/correlations/CorrelationsPage.test.tsx b/public/app/features/correlations/CorrelationsPage.test.tsx index 6c046e76d06..7299667a025 100644 --- a/public/app/features/correlations/CorrelationsPage.test.tsx +++ b/public/app/features/correlations/CorrelationsPage.test.tsx @@ -4,16 +4,17 @@ import { merge, uniqueId } from 'lodash'; import { openMenu } from 'react-select-event'; import { Observable } from 'rxjs'; import { TestProvider } from 'test/helpers/TestProvider'; -import { MockDataSourceApi } from 'test/mocks/datasource_srv'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; -import { DataSourcePluginMeta, SupportedTransformationType } from '@grafana/data'; +import { DataSourceInstanceSettings, SupportedTransformationType } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { BackendSrv, setDataSourceSrv, BackendSrvRequest, reportInteraction } from '@grafana/runtime'; +import { BackendSrv, BackendSrvRequest, reportInteraction, setBackendSrv, setAppEvents } from '@grafana/runtime'; +import appEvents from 'app/core/app_events'; import { contextSrv } from 'app/core/services/context_srv'; +import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; import { configureStore } from 'app/store/configureStore'; -import { mockDataSource, MockDataSourceSrv } from '../alerting/unified/mocks'; +import { mockDataSource } from '../alerting/unified/mocks'; import CorrelationsPage from './CorrelationsPage'; import { @@ -25,8 +26,11 @@ import { } from './__mocks__/useCorrelations.mocks'; import { Correlation, CreateCorrelationParams, OmitUnion } from './types'; +// Set app events up, otherwise plugin modules will fail to load +setAppEvents(appEvents); + const renderWithContext = async ( - datasources: ConstructorParameters[0] = {}, + datasources: Record, correlations: Correlation[] = [] ) => { const backend = { @@ -86,17 +90,8 @@ const renderWithContext = async ( }, } as unknown as BackendSrv; const grafanaContext = getGrafanaContextMock({ backend }); - - const dsServer = new MockDataSourceSrv(datasources); - dsServer.get = (name: string) => { - const dsApi = new MockDataSourceApi(name); - dsApi.components = { - QueryEditor: () => <>{name} query editor, - }; - return Promise.resolve(dsApi); - }; - - setDataSourceSrv(dsServer); + setBackendSrv(backend); + setupDataSources(...Object.values(datasources)); const renderResult = render( @@ -210,10 +205,9 @@ describe('CorrelationsPage', () => { name: 'loki', readOnly: false, jsonData: {}, - access: 'direct', type: 'datasource', }, - { logs: true } + { logs: true, module: 'core:plugin/loki' } ), prometheus: mockDataSource( { @@ -221,10 +215,9 @@ describe('CorrelationsPage', () => { name: 'prometheus', readOnly: false, jsonData: {}, - access: 'direct', type: 'datasource', }, - { metrics: true } + { metrics: true, module: 'core:plugin/prometheus' } ), }); }); @@ -319,11 +312,11 @@ describe('CorrelationsPage', () => { name: 'loki', readOnly: false, jsonData: {}, - access: 'direct', type: 'datasource', }, { logs: true, + module: 'core:plugin/loki', } ), prometheus: mockDataSource( @@ -332,11 +325,11 @@ describe('CorrelationsPage', () => { name: 'prometheus', readOnly: false, jsonData: {}, - access: 'direct', type: 'datasource', }, { metrics: true, + module: 'core:plugin/prometheus', } ), elastic: mockDataSource( @@ -345,12 +338,12 @@ describe('CorrelationsPage', () => { name: 'elastic', readOnly: false, jsonData: {}, - access: 'direct', type: 'datasource', }, { metrics: true, logs: true, + module: 'core:plugin/elasticsearch', } ), }, @@ -589,6 +582,7 @@ describe('CorrelationsPage', () => { }, { logs: true, + module: 'core:plugin/loki', } ), }, @@ -675,15 +669,17 @@ describe('CorrelationsPage', () => { beforeEach(async () => { await renderWithContext( { - loki: mockDataSource({ - uid: 'loki', - name: 'loki', - readOnly: true, - jsonData: {}, - access: 'direct', - meta: { info: { logos: {} } } as DataSourcePluginMeta, - type: 'datasource', - }), + loki: mockDataSource( + { + uid: 'loki', + name: 'loki', + readOnly: true, + jsonData: {}, + access: 'direct', + type: 'datasource', + }, + { logs: true, module: 'core:plugin/loki' } + ), }, correlations ); diff --git a/public/app/features/correlations/Forms/QueryEditorField.test.tsx b/public/app/features/correlations/Forms/QueryEditorField.test.tsx index bb4b4d33a52..96903c463b2 100644 --- a/public/app/features/correlations/Forms/QueryEditorField.test.tsx +++ b/public/app/features/correlations/Forms/QueryEditorField.test.tsx @@ -4,7 +4,7 @@ import { FormProvider, useForm } from 'react-hook-form'; import { MockDataSourceApi } from 'test/mocks/datasource_srv'; import { setDataSourceSrv } from '@grafana/runtime'; -import { MockDataSourceSrv } from 'app/features/alerting/unified/mocks'; +import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; import { QueryEditorField } from './QueryEditorField'; @@ -25,7 +25,7 @@ const renderWithContext = ( children: ReactNode, getHandler: (name: string) => Promise = defaultGetHandler ) => { - const dsServer = new MockDataSourceSrv({}); + const dsServer = setupDataSources(); dsServer.get = getHandler; setDataSourceSrv(dsServer); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx index 8995a7a0c30..c7a1d1fec86 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx @@ -2,16 +2,14 @@ import userEvent from '@testing-library/user-event'; import { render } from 'test/test-utils'; import { byTestId } from 'testing-library-selector'; -import { DataSourceApi } from '@grafana/data'; -import { PromOptions, PrometheusDatasource } from '@grafana/prometheus'; -import { config, locationService, setDataSourceSrv, setPluginLinksHook } from '@grafana/runtime'; +import { PromOptions } from '@grafana/prometheus'; +import { config, locationService, setPluginLinksHook } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; import * as ruler from 'app/features/alerting/unified/api/ruler'; import * as ruleActionButtons from 'app/features/alerting/unified/components/rules/RuleActionsButtons'; import * as alertingAbilities from 'app/features/alerting/unified/hooks/useAbilities'; import { mockAlertRuleApi, setupMswServer } from 'app/features/alerting/unified/mockApi'; import { - MockDataSourceSrv, grantUserPermissions, mockDataSource, mockFolder, @@ -20,13 +18,12 @@ import { mockRulerAlertingRule, mockRulerRuleGroup, } from 'app/features/alerting/unified/mocks'; +import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form'; -import * as configDS from 'app/features/alerting/unified/utils/config'; import { Annotation } from 'app/features/alerting/unified/utils/constants'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; -import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { configureStore } from 'app/store/configureStore'; import { AccessControlAction, DashboardDataDTO } from 'app/types'; import { AlertQuery, PromRulesResponse } from 'app/types/unified-alerting-dto'; @@ -44,7 +41,6 @@ import { PanelDataAlertingTab, PanelDataAlertingTabRendered } from './PanelDataA jest.mock('app/features/alerting/unified/api/prometheus'); jest.mock('app/features/alerting/unified/api/ruler'); -jest.spyOn(configDS, 'getAllDataSources'); jest.spyOn(ruleActionButtons, 'matchesWidth').mockReturnValue(false); jest.spyOn(ruler, 'rulerUrlBuilder'); jest.spyOn(alertingAbilities, 'useAlertRuleAbility'); @@ -55,22 +51,34 @@ setPluginLinksHook(() => ({ })); const dataSources = { - prometheus: mockDataSource({ - name: 'Prometheus', - type: DataSourceType.Prometheus, - isDefault: false, - }), - default: mockDataSource({ - name: 'Default', - type: DataSourceType.Prometheus, - isDefault: true, - }), + prometheus: mockDataSource( + { + name: 'Prometheus', + type: DataSourceType.Prometheus, + isDefault: false, + }, + { alerting: true, module: 'core:plugin/prometheus' } + ), + default: mockDataSource( + { + name: 'Default', + type: DataSourceType.Prometheus, + isDefault: true, + }, + { alerting: true, module: 'core:plugin/prometheus' } + ), + prometheusMinInterval: mockDataSource( + { + name: 'Prometheus Min Interval', + type: DataSourceType.Prometheus, + isDefault: false, + jsonData: { manageAlerts: true, timeInterval: '7m' }, + }, + { alerting: true, module: 'core:plugin/prometheus' } + ), }; -dataSources.prometheus.meta.alerting = true; -dataSources.default.meta.alerting = true; const mocks = { - getAllDataSources: jest.mocked(configDS.getAllDataSources), useAlertRuleAbilityMock: jest.mocked(alertingAbilities.useAlertRuleAbility), rulerBuilderMock: jest.mocked(ruler.rulerUrlBuilder), }; @@ -182,14 +190,8 @@ describe('PanelAlertTabContent', () => { ]); jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValue(mockFolder()); + setupDataSources(...Object.values(dataSources)); - mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); - const dsService = new MockDataSourceSrv(dataSources); - dsService.datasources[dataSources.prometheus.uid] = new PrometheusDatasource( - dataSources.prometheus - ) as DataSourceApi; - dsService.datasources[dataSources.default.uid] = new PrometheusDatasource(dataSources.default) as DataSourceApi; - setDataSourceSrv(dsService); mocks.rulerBuilderMock.mockReturnValue({ rules: () => ({ path: `api/ruler/${GRAFANA_RULES_SOURCE_NAME}/api/v1/rules` }), namespace: () => ({ path: 'ruler' }), @@ -281,11 +283,13 @@ describe('PanelAlertTabContent', () => { }); it('Will take into account datasource minInterval', async () => { - (getDatasourceSrv() as unknown as MockDataSourceSrv).datasources[dataSources.prometheus.uid].interval = '7m'; - dashboard.panels = [ new PanelModel({ ...panel, + datasource: { + type: 'prometheus', + uid: dataSources.prometheusMinInterval.uid, + }, maxDataPoints: 100, }), ]; @@ -298,7 +302,7 @@ describe('PanelAlertTabContent', () => { refId: 'A', datasource: { type: 'prometheus', - uid: 'mock-ds-2', + uid: 'mock-ds-4', }, interval: '', intervalMs: 420000, diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts index f204eae7138..fe263ee3bde 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts @@ -2,7 +2,6 @@ import { of } from 'rxjs'; import { DataQueryRequest, DataSourceApi, LoadingState, PanelPlugin } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; -import { setDataSourceSrv } from '@grafana/runtime'; import { CancelActivationHandler, CustomVariable, @@ -14,7 +13,8 @@ import { SceneVariableSet, VizPanel, } from '@grafana/scenes'; -import { mockDataSource, MockDataSourceSrv } from 'app/features/alerting/unified/mocks'; +import { mockDataSource } from 'app/features/alerting/unified/mocks'; +import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; import * as libAPI from 'app/features/library-panels/state/api'; @@ -61,13 +61,16 @@ jest.mock('@grafana/runtime', () => ({ })); const dataSources = { - ds1: mockDataSource({ - uid: 'ds1', - type: DataSourceType.Prometheus, - }), + ds1: mockDataSource( + { + uid: 'ds1', + type: DataSourceType.Prometheus, + }, + { module: 'core:plugin/prometheus' } + ), }; -setDataSourceSrv(new MockDataSourceSrv(dataSources)); +setupDataSources(...Object.values(dataSources)); let deactivate: CancelActivationHandler | undefined; diff --git a/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx index 6bd55b389aa..f947534d246 100644 --- a/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx @@ -1,9 +1,8 @@ -import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { TestProvider } from 'test/helpers/TestProvider'; +import { render, screen, within } from 'test/test-utils'; -import { locationService, setAngularLoader, setDataSourceSrv } from '@grafana/runtime'; -import { mockDataSource, MockDataSourceSrv } from 'app/features/alerting/unified/mocks'; +import { locationService, setAngularLoader } from '@grafana/runtime'; +import { mockDataSource } from 'app/features/alerting/unified/mocks'; +import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; import { DashboardModel } from '../../state/DashboardModel'; import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures'; @@ -18,11 +17,7 @@ function setup(dashboard: DashboardModel, editIndex?: number) { }, }; - return render( - - - - ); + return render(); } describe('AnnotationsSettings', () => { @@ -56,7 +51,7 @@ describe('AnnotationsSettings', () => { ), }; - setDataSourceSrv(new MockDataSourceSrv(dataSources)); + setupDataSources(...Object.values(dataSources)); const getTableBody = () => screen.getAllByRole('rowgroup')[1]; const getTableBodyRows = () => within(getTableBody()).getAllByRole('row'); @@ -149,7 +144,7 @@ describe('AnnotationsSettings', () => { }, ]; - setup(dashboard); + const { user } = setup(dashboard); // Check that we have sorting buttons expect(within(getTableBodyRows()[0]).queryByRole('button', { name: 'Move up' })).not.toBeInTheDocument(); @@ -166,9 +161,9 @@ describe('AnnotationsSettings', () => { expect(within(getTableBodyRows()[1]).queryByText(/annotation 2/i)).toBeInTheDocument(); expect(within(getTableBodyRows()[2]).queryByText(/annotation 3/i)).toBeInTheDocument(); - await userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'Move down' })[0]); - await userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'Move down' })[1]); - await userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'Move up' })[0]); + await user.click(within(getTableBody()).getAllByRole('button', { name: 'Move down' })[0]); + await user.click(within(getTableBody()).getAllByRole('button', { name: 'Move down' })[1]); + await user.click(within(getTableBody()).getAllByRole('button', { name: 'Move up' })[0]); // Checking if it has changed the sorting accordingly expect(within(getTableBodyRows()[0]).queryByText(/annotation 3/i)).toBeInTheDocument(); @@ -177,9 +172,9 @@ describe('AnnotationsSettings', () => { }); test('Adding a new annotation', async () => { - setup(dashboard); + const { user } = setup(dashboard); - await userEvent.click(screen.getByRole('button', { name: 'Add annotation query' })); + await user.click(screen.getByRole('button', { name: 'Add annotation query' })); expect(locationService.getSearchObject().editIndex).toBe('1'); expect(dashboard.annotations.list.length).toBe(2); @@ -193,18 +188,18 @@ describe('AnnotationsSettings', () => { enable: true, }); - setup(dashboard, 1); + const { user } = setup(dashboard, 1); const nameInput = screen.getByRole('textbox', { name: /name/i }); - await userEvent.clear(nameInput); - await userEvent.type(nameInput, 'My Prometheus Annotation'); + await user.clear(nameInput); + await user.type(nameInput, 'My Prometheus Annotation'); - await userEvent.click(screen.getByPlaceholderText(/testdata/i)); + await user.click(screen.getByPlaceholderText(/testdata/i)); expect(await screen.findByText(/Prometheus/i)).toBeVisible(); expect(screen.queryAllByText(/testdata/i)).toHaveLength(1); - await userEvent.click(screen.getByText(/prometheus/i)); + await user.click(screen.getByText(/prometheus/i)); expect(screen.getByRole('checkbox', { name: /hidden/i })).not.toBeChecked(); }); @@ -222,9 +217,9 @@ describe('AnnotationsSettings', () => { type: 'dashboard', }, ]; - setup(dashboard, 1); // Edit the not built-in annotations + const { user } = setup(dashboard, 1); // Edit the not built-in annotations - await userEvent.click(screen.getByRole('button', { name: 'Delete' })); + await user.click(screen.getByRole('button', { name: 'Delete' })); expect(locationService.getSearchObject().editIndex).toBe(undefined); expect(dashboard.annotations.list.length).toBe(1); // started with two diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts index c41b87c0e4b..70a80fb2da1 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.test.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts @@ -2,11 +2,11 @@ import { each, map } from 'lodash'; import { DataLinkBuiltInVars, MappingType, VariableHide } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; -import { setDataSourceSrv } from '@grafana/runtime'; import { FieldConfigSource } from '@grafana/schema'; import { config } from 'app/core/config'; import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants'; -import { mockDataSource, MockDataSourceSrv } from 'app/features/alerting/unified/mocks'; +import { mockDataSource } from 'app/features/alerting/unified/mocks'; +import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { DashboardModel } from '../state/DashboardModel'; @@ -41,7 +41,7 @@ const dataSources = { }), }; -setDataSourceSrv(new MockDataSourceSrv(dataSources)); +setupDataSources(...Object.values(dataSources)); describe('DashboardModel', () => { describe('when creating dashboard with old schema', () => { diff --git a/public/app/features/templating/template_srv.test.ts b/public/app/features/templating/template_srv.test.ts index 640143a289e..58da8d87c04 100644 --- a/public/app/features/templating/template_srv.test.ts +++ b/public/app/features/templating/template_srv.test.ts @@ -1,5 +1,5 @@ import { dateTime, QueryVariableModel, TimeRange, TypedVariableModel } from '@grafana/data'; -import { setDataSourceSrv, VariableInterpolation } from '@grafana/runtime'; +import { VariableInterpolation } from '@grafana/runtime'; import { ConstantVariable, CustomVariable, @@ -13,10 +13,11 @@ import { TestVariable, } from '@grafana/scenes'; import { VariableFormatID } from '@grafana/schema'; +import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; import { silenceConsoleOutput } from '../../../test/core/utils/silenceConsoleOutput'; import { initTemplateSrv } from '../../../test/helpers/initTemplateSrv'; -import { mockDataSource, MockDataSourceSrv } from '../alerting/unified/mocks'; +import { mockDataSource } from '../alerting/unified/mocks'; import { VariableAdapter, variableAdapters } from '../variables/adapters'; import { createAdHocVariableAdapter } from '../variables/adhoc/adapter'; import { createQueryVariableAdapter } from '../variables/query/adapter'; @@ -218,16 +219,14 @@ describe('templateSrv', () => { { type: 'adhoc', name: 'test', datasource: { uid: 'oogle' }, filters: [1] }, { type: 'adhoc', name: 'test2', datasource: { uid: '$ds' }, filters: [2] }, ]); - setDataSourceSrv( - new MockDataSourceSrv({ - oogle: mockDataSource({ - name: 'oogle', - uid: 'oogle', - }), - logstash: mockDataSource({ - name: 'logstash', - uid: 'logstash-id', - }), + setupDataSources( + mockDataSource({ + name: 'oogle', + uid: 'oogle', + }), + mockDataSource({ + name: 'logstash', + uid: 'logstash-id', }) ); }); diff --git a/public/app/features/trails/DataTrail.test.tsx b/public/app/features/trails/DataTrail.test.tsx index ecb928383d2..ec3ebdb88bc 100644 --- a/public/app/features/trails/DataTrail.test.tsx +++ b/public/app/features/trails/DataTrail.test.tsx @@ -1,9 +1,10 @@ import { VariableHide } from '@grafana/data'; -import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import { locationService } from '@grafana/runtime'; import { AdHocFiltersVariable, ConstantVariable, sceneGraph } from '@grafana/scenes'; +import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; -import { MockDataSourceSrv, mockDataSource } from '../alerting/unified/mocks'; +import { mockDataSource } from '../alerting/unified/mocks'; import { activateFullSceneTree } from '../dashboard-scene/utils/test-utils'; import { DataTrail } from './DataTrail'; @@ -26,12 +27,11 @@ jest.mock('./otel/api', () => ({ describe('DataTrail', () => { beforeAll(() => { jest.spyOn(DataTrail.prototype, 'checkDataSourceForOTelResources').mockImplementation(() => Promise.resolve()); - setDataSourceSrv( - new MockDataSourceSrv({ - prom: mockDataSource({ - name: 'Prometheus', - type: DataSourceType.Prometheus, - }), + + setupDataSources( + mockDataSource({ + name: 'Prometheus', + type: DataSourceType.Prometheus, }) ); }); diff --git a/public/app/features/trails/TrailStore/TrailStore.test.ts b/public/app/features/trails/TrailStore/TrailStore.test.ts index b92abceca20..ee6b79c9049 100644 --- a/public/app/features/trails/TrailStore/TrailStore.test.ts +++ b/public/app/features/trails/TrailStore/TrailStore.test.ts @@ -1,8 +1,9 @@ -import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import { locationService } from '@grafana/runtime'; import { AdHocFiltersVariable, sceneGraph, sceneUtils } from '@grafana/scenes'; +import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; -import { MockDataSourceSrv, mockDataSource } from '../../alerting/unified/mocks'; +import { mockDataSource } from '../../alerting/unified/mocks'; import { DataTrail } from '../DataTrail'; import { TRAIL_BOOKMARKS_KEY, RECENT_TRAILS_KEY, VAR_FILTERS } from '../shared'; @@ -31,13 +32,10 @@ describe('TrailStore', () => { jest.useFakeTimers(); // Having the mock service set up is required for activating the loaded trails - setDataSourceSrv( - new MockDataSourceSrv({ - prom: mockDataSource({ - name: 'Prometheus', - type: DataSourceType.Prometheus, - uid: 'ds', - }), + setupDataSources( + mockDataSource({ + name: 'Prometheus', + type: DataSourceType.Prometheus, }) ); }); diff --git a/public/app/features/trails/otel/utils.test.ts b/public/app/features/trails/otel/utils.test.ts index 572643831d7..aa81281c92a 100644 --- a/public/app/features/trails/otel/utils.test.ts +++ b/public/app/features/trails/otel/utils.test.ts @@ -1,7 +1,8 @@ import { AdHocVariableFilter, MetricFindValue } from '@grafana/data'; -import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import { locationService } from '@grafana/runtime'; import { AdHocFiltersVariable, ConstantVariable, sceneGraph } from '@grafana/scenes'; -import { mockDataSource, MockDataSourceSrv } from 'app/features/alerting/unified/mocks'; +import { mockDataSource } from 'app/features/alerting/unified/mocks'; +import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; import { activateFullSceneTree } from 'app/features/dashboard-scene/utils/test-utils'; @@ -205,12 +206,10 @@ describe('updateOtelJoinWithGroupLeft', () => { beforeEach(() => { jest.spyOn(DataTrail.prototype, 'checkDataSourceForOTelResources').mockImplementation(() => Promise.resolve()); - setDataSourceSrv( - new MockDataSourceSrv({ - prom: mockDataSource({ - name: 'Prometheus', - type: DataSourceType.Prometheus, - }), + setupDataSources( + mockDataSource({ + name: 'Prometheus', + type: DataSourceType.Prometheus, }) ); trail = new DataTrail({ @@ -274,12 +273,11 @@ describe('getProdOrDefaultEnv', () => { describe('util functions that rely on trail and variable setup', () => { beforeAll(() => { jest.spyOn(DataTrail.prototype, 'checkDataSourceForOTelResources').mockImplementation(() => Promise.resolve()); - setDataSourceSrv( - new MockDataSourceSrv({ - prom: mockDataSource({ - name: 'Prometheus', - type: DataSourceType.Prometheus, - }), + + setupDataSources( + mockDataSource({ + name: 'Prometheus', + type: DataSourceType.Prometheus, }) ); }); @@ -332,6 +330,12 @@ describe('util functions that rely on trail and variable setup', () => { useOtelExperience: true, nonPromotedOtelResources, }); + setupDataSources( + mockDataSource({ + name: 'Prometheus', + type: DataSourceType.Prometheus, + }) + ); locationService.push(preTrailUrl); activateFullSceneTree(trail); getOtelGroupLeftVar(trail).setState({ value: 'attribute1,attribute2' }); diff --git a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.test.tsx b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.test.tsx index 8fa71495f28..0b794a48061 100644 --- a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.test.tsx +++ b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.test.tsx @@ -2,8 +2,8 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { getDefaultTimeRange, LoadingState } from '@grafana/data'; -import { setDataSourceSrv } from '@grafana/runtime'; -import { mockDataSource, MockDataSourceSrv } from 'app/features/alerting/unified/mocks'; +import { mockDataSource } from 'app/features/alerting/unified/mocks'; +import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; @@ -30,11 +30,7 @@ jest.mock('app/core/config', () => ({ }, })); -setDataSourceSrv( - new MockDataSourceSrv({ - test: mockDataSource({ isDefault: true }), - }) -); +setupDataSources(mockDataSource({ isDefault: true })); describe('DashboardQueryEditor', () => { const mockOnChange = jest.fn(); From 6dae264a062d6c8a04dca43908556546bad04567 Mon Sep 17 00:00:00 2001 From: Marcus Andersson Date: Thu, 20 Feb 2025 10:58:55 +0100 Subject: [PATCH 08/11] Bugfix: Fixed breadcrumbs in plugin catalog/connections details page (#100903) Fixing so the the breadcrumbs works properly. --- public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx index d9f7c2e1375..60074bb053a 100644 --- a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx +++ b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx @@ -155,6 +155,7 @@ export const usePluginDetailsTabs = ( const navModel: NavModelItem = { text: plugin?.name ?? '', img: plugin?.info.logos.small, + url: pathname, children: [ { text: PluginTabLabels.OVERVIEW, From 32643c933ef7c7b1de90140563c33a269814ea8a Mon Sep 17 00:00:00 2001 From: Eric Leijonmarck Date: Thu, 20 Feb 2025 10:26:46 +0000 Subject: [PATCH 09/11] LBAC for datasources: GA (#99511) * GA: feature toggle removal of `teamHttpHeaders` * Apply suggestions from code review Co-authored-by: Gabriel MABILLE Co-authored-by: Ieva --------- Co-authored-by: Gabriel MABILLE Co-authored-by: Ieva --- .../configure-teamlbac-for-prometheus/index.md | 10 ++++++++-- .../teamlbac/create-teamlbac-rules/index.md | 2 -- .../configure-grafana/feature-toggles/index.md | 1 - packages/grafana-data/src/types/featureToggles.gen.ts | 1 - pkg/api/datasources.go | 9 +++------ pkg/api/datasources_test.go | 2 +- pkg/services/datasources/service/datasource.go | 2 +- pkg/services/datasources/service/datasource_test.go | 8 ++++---- pkg/services/featuremgmt/registry.go | 9 --------- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 ---- pkg/services/featuremgmt/toggles_gen.json | 1 + 12 files changed, 18 insertions(+), 32 deletions(-) diff --git a/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-prometheus/index.md b/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-prometheus/index.md index ab2c3f78656..29f8467ad6d 100644 --- a/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-prometheus/index.md +++ b/docs/sources/administration/data-source-management/teamlbac/configure-teamlbac-for-prometheus/index.md @@ -22,11 +22,17 @@ You cannot configure LBAC rules for Grafana-provisioned data sources from the UI ## Before you begin -To be able to use LBAC for data sources rules, you need to enable the feature toggle `teamHttpHeadersMimir` on your Grafana instance. Contact support to enable the feature toggle for you. +To be able to use LBAC for Prometheus data sources, you need to enable the feature toggle `teamHttpHeadersMimir` on your Grafana instance. Contact support to enable the feature toggle for you. - Be sure that you have the permission setup to create a Prometheus tenant in Grafana Cloud - Be sure that you have admin data source permissions for Grafana. +## Grafana Cloud + +LBAC for data sources is available in private preview on Grafana Cloud for Prometheus created with basic authentication. Prometheus data sources for LBAC for data sources can only be created, provisioning is currently not available. + +You cannot configure LBAC rules for Grafana-provisioned data sources from the UI. We recommend that you replicate the setting of the provisioned data source in a new data source as described in [LBAC Configuration for New Prometheus Data Source](https://grafana.com/docs/grafana/latest/administration/data-source-management/teamlbac/configure-teamlbac-for-Prometheus/#task-1-lbac-configuration-for-new-Prometheus-data-source) and then add the LBAC configuration to the new data source. + ### Permissions We recommend that you remove all permissions for roles and teams that are not required to access the data source. This will help to ensure that only the required teams have access to the data source. The recommended permissions are `Admin` permission and only add the teams `Query` permissions that you want to add LBAC for data sources rules for. @@ -57,7 +63,7 @@ You cannot configure LBAC rules for Grafana-provisioned data sources from the UI ## Before you begin -To be able to use LBAC for data sources rules, you need to enable the feature toggle `teamHttpHeaders` on your Grafana instance. Contact support to enable the feature toggle for you. +To be able to use LBAC for Prometheus data sources, you need to enable the feature toggle `teamHttpHeadersMimir` on your Grafana instance. Contact support to enable the feature toggle for you. - Be sure that you have the permission setup to create a cluster in your Grafana - Be sure that you have admin plugins permissions for Grafana. diff --git a/docs/sources/administration/data-source-management/teamlbac/create-teamlbac-rules/index.md b/docs/sources/administration/data-source-management/teamlbac/create-teamlbac-rules/index.md index 3942490c0ff..f76dd3d64bb 100644 --- a/docs/sources/administration/data-source-management/teamlbac/create-teamlbac-rules/index.md +++ b/docs/sources/administration/data-source-management/teamlbac/create-teamlbac-rules/index.md @@ -17,8 +17,6 @@ LBAC for data sources is available for LBAC-supported data sources created with ## Before you begin -To be able to use LBAC for data sources rules, you need to enable the feature toggle `teamHttpHeaders` on your Grafana instance. Contact support to enable the feature toggle for you. - - Be sure that you have the permission setup to create a Loki tenant in Grafana Cloud. - Be sure that you have admin data source permissions for Grafana. - Be sure that you have a team setup in Grafana. diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index b4e37ab972f..a3e28d65042 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -107,7 +107,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `reportingRetries` | Enables rendering retries for the reporting feature | | `externalServiceAccounts` | Automatic service account and token setup for plugins | | `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches | -| `teamHttpHeaders` | Enables LBAC for datasources to apply LogQL filtering of logs to the client requests for users in teams | | `pdfTables` | Enables generating table data as PDF in reporting | | `canvasPanelPanZoom` | Allow pan and zoom in canvas panel | | `regressionTransformation` | Enables regression analysis transformation | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 89b94fdda3f..2b2f4befd4f 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -117,7 +117,6 @@ export interface FeatureToggles { cloudWatchBatchQueries?: boolean; recoveryThreshold?: boolean; lokiStructuredMetadata?: boolean; - teamHttpHeaders?: boolean; cachingOptimizeSerializationMemoryUsage?: boolean; prometheusPromQAIL?: boolean; prometheusCodeModeMetricNamesSearch?: boolean; diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index e096b3d3328..0eef6a7ce16 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -20,7 +20,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -394,11 +393,9 @@ func (hs *HTTPServer) AddDataSource(c *contextmodel.ReqContext) response.Respons // It's forbidden to update the rules from the datasource api. // team HTTP headers update have to be done through `updateDatasourceLBACRules` - if hs.Features != nil && hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagTeamHttpHeaders) { - if cmd.JsonData != nil { - if _, ok := cmd.JsonData.CheckGet("teamHttpHeaders"); ok { - return response.Error(http.StatusForbidden, "Cannot create datasource with team HTTP headers, need to use updateDatasourceLBACRules API", nil) - } + if cmd.JsonData != nil { + if _, ok := cmd.JsonData.CheckGet("teamHttpHeaders"); ok { + return response.Error(http.StatusForbidden, "Cannot create datasource with team HTTP headers, need to use updateDatasourceLBACRules API", nil) } } diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go index 10a8237d1b3..fa057d3ccd5 100644 --- a/pkg/api/datasources_test.go +++ b/pkg/api/datasources_test.go @@ -229,7 +229,7 @@ func TestAddDataSourceTeamHTTPHeaders(t *testing.T) { expectedDatasource: &datasources.DataSource{}, }, Cfg: setting.NewCfg(), - Features: featuremgmt.WithFeatures(featuremgmt.FlagTeamHttpHeaders), + Features: featuremgmt.WithFeatures(), accesscontrolService: actest.FakeService{}, AccessControl: actest.FakeAccessControl{ ExpectedEvaluate: true, diff --git a/pkg/services/datasources/service/datasource.go b/pkg/services/datasources/service/datasource.go index e5d107615bc..528651939b3 100644 --- a/pkg/services/datasources/service/datasource.go +++ b/pkg/services/datasources/service/datasource.go @@ -536,7 +536,7 @@ func (s *Service) UpdateDataSource(ctx context.Context, cmd *datasources.UpdateD // preserve existing lbac rules when updating datasource if we're not updating lbac rules // TODO: Refactor to store lbac rules separate from a datasource - if s.features != nil && s.features.IsEnabled(ctx, featuremgmt.FlagTeamHttpHeaders) && !cmd.AllowLBACRuleUpdates { + if !cmd.AllowLBACRuleUpdates { s.logger.Debug("Overriding LBAC rules with stored ones using updateLBACRules API", "reason", "overriding_lbac_rules_from_datasource_api", "datasource_id", dataSource.ID, diff --git a/pkg/services/datasources/service/datasource_test.go b/pkg/services/datasources/service/datasource_test.go index b2439261e1b..a94c54a08bd 100644 --- a/pkg/services/datasources/service/datasource_test.go +++ b/pkg/services/datasources/service/datasource_test.go @@ -572,7 +572,7 @@ func TestService_UpdateDataSource(t *testing.T) { t.Run("Should update LBAC rules when updating from API", func(t *testing.T) { dsService := initDSService(t) - dsService.features = featuremgmt.WithFeatures(featuremgmt.FlagTeamHttpHeaders) + dsService.features = featuremgmt.WithFeatures() // Create a datasource with existing LBAC rules existingRules := []interface{}{ @@ -629,7 +629,7 @@ func TestService_UpdateDataSource(t *testing.T) { }) t.Run("Should preserve LBAC rules when not updating from API", func(t *testing.T) { dsService := initDSService(t) - dsService.features = featuremgmt.WithFeatures(featuremgmt.FlagTeamHttpHeaders) + dsService.features = featuremgmt.WithFeatures() // Create a datasource with existing LBAC rules existingRules := []interface{}{ map[string]interface{}{ @@ -680,7 +680,7 @@ func TestService_UpdateDataSource(t *testing.T) { t.Run("Should not remove stored rules without AllowLBACRuleUpdates", func(t *testing.T) { dsService := initDSService(t) - dsService.features = featuremgmt.WithFeatures(featuremgmt.FlagTeamHttpHeaders) + dsService.features = featuremgmt.WithFeatures() // Create a datasource with existing LBAC rules existingRules := []interface{}{ @@ -720,7 +720,7 @@ func TestService_UpdateDataSource(t *testing.T) { t.Run("Should not populate empty stored rules without AllowLBACRuleUpdates", func(t *testing.T) { dsService := initDSService(t) - dsService.features = featuremgmt.WithFeatures(featuremgmt.FlagTeamHttpHeaders) + dsService.features = featuremgmt.WithFeatures() // Create a datasource with empty LBAC rules jsonData := simplejson.New() diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index a0ea49295ba..6ef1d2155fc 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -743,15 +743,6 @@ var ( Owner: grafanaObservabilityLogsSquad, Expression: "true", }, - { - Name: "teamHttpHeaders", - Description: "Enables LBAC for datasources to apply LogQL filtering of logs to the client requests for users in teams", - Stage: FeatureStagePublicPreview, - FrontendOnly: false, - AllowSelfServe: true, - Owner: identityAccessTeam, - Expression: "true", - }, { Name: "cachingOptimizeSerializationMemoryUsage", Description: "If enabled, the caching backend gradually serializes query responses for the cache, comparing against the configured `[caching]max_value_mb` value as it goes. This can can help prevent Grafana from running out of memory while attempting to cache very large query responses.", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 0d508340897..ef41ffea4db 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -98,7 +98,6 @@ queryServiceFromUI,experimental,@grafana/grafana-app-platform-squad,false,false, cloudWatchBatchQueries,preview,@grafana/aws-datasources,false,false,false recoveryThreshold,GA,@grafana/alerting-squad,false,true,false lokiStructuredMetadata,GA,@grafana/observability-logs,false,false,false -teamHttpHeaders,preview,@grafana/identity-access-team,false,false,false cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-experience-squad,false,false,false prometheusPromQAIL,experimental,@grafana/oss-big-tent,false,false,true prometheusCodeModeMetricNamesSearch,experimental,@grafana/oss-big-tent,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 8537365da4b..9aa900797ce 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -403,10 +403,6 @@ const ( // Enables the loki data source to request structured metadata from the Loki server FlagLokiStructuredMetadata = "lokiStructuredMetadata" - // FlagTeamHttpHeaders - // Enables LBAC for datasources to apply LogQL filtering of logs to the client requests for users in teams - FlagTeamHttpHeaders = "teamHttpHeaders" - // FlagCachingOptimizeSerializationMemoryUsage // If enabled, the caching backend gradually serializes query responses for the cache, comparing against the configured `[caching]max_value_mb` value as it goes. This can can help prevent Grafana from running out of memory while attempting to cache very large query responses. FlagCachingOptimizeSerializationMemoryUsage = "cachingOptimizeSerializationMemoryUsage" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index b4b6bde8bfa..983c65e8298 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3846,6 +3846,7 @@ "name": "teamHttpHeaders", "resourceVersion": "1738590709387", "creationTimestamp": "2023-10-17T10:23:54Z", + "deletionTimestamp": "2025-02-19T10:48:55Z", "annotations": { "grafana.app/updatedTimestamp": "2025-02-03 13:51:49.3871 +0000 UTC" } From 63536a1cbec89af0ebc125b8dcd6a97dc661611a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irene=20Rodr=C3=ADguez?= Date: Thu, 20 Feb 2025 11:37:15 +0100 Subject: [PATCH 10/11] Fix broken links and relfrefs (#101042) Co-authored-by: Jack Baldry --- .../introduction/grafana-enterprise.md | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/sources/introduction/grafana-enterprise.md b/docs/sources/introduction/grafana-enterprise.md index e9317e632a4..15db695b9bc 100644 --- a/docs/sources/introduction/grafana-enterprise.md +++ b/docs/sources/introduction/grafana-enterprise.md @@ -13,7 +13,7 @@ weight: 200 Grafana Enterprise is a commercial edition of Grafana that includes additional features not found in the open source version. -Building on everything you already know and love about Grafana open source, Grafana Enterprise includes [exclusive datasource plugins]({{< relref "#enterprise-data-sources" >}}) and [additional features]({{< relref "#enterprise-features" >}}). You also get 24x7x365 support and training from the core Grafana team. +Building on everything you already know and love about Grafana open source, Grafana Enterprise includes [exclusive data source plugins](#enterprise-data-sources) and [additional features](#enterprise-features). You also get 24x7x365 support and training from the core Grafana team. To learn more about Grafana Enterprise, refer to [our product page](/enterprise). @@ -21,7 +21,7 @@ To learn more about Grafana Enterprise, refer to [our product page](/enterprise) Many Grafana Enterprise features are also available in [Grafana Cloud](/docs/grafana-cloud) Free, Pro, and Advanced accounts. For details, refer to [Grafana Cloud pricing](/pricing/#featuresTable). -To migrate to Grafana Cloud, refer to [Migrate from Grafana Enterprise to Grafana Cloud](/docs/grafana-cloud/account-management/e2c-guide/). +To migrate to Grafana Cloud, refer to [Migrate from Grafana Enterprise to Grafana Cloud](/docs/grafana//administration/migration-guide/) ## Authentication @@ -29,7 +29,7 @@ Grafana Enterprise includes integrations with more ways to authenticate your use ### Team sync -[Team sync]({{< relref "../setup-grafana/configure-security/configure-team-sync" >}}) allows you to set up synchronization between teams in Grafana and teams in your auth provider so that your users automatically end up in the right team. +[Team sync](/docs/grafana//setup-grafana/configure-security/configure-team-sync/) allows you to set up synchronization between teams in Grafana and teams in your auth provider so that your users automatically end up in the right team. Supported auth providers: @@ -45,32 +45,32 @@ Supported auth providers: ### Enhanced LDAP integration -With [enhanced LDAP integration]({{< relref "../setup-grafana/configure-security/configure-authentication/enhanced-ldap" >}}), you can set up active LDAP synchronization. +With [enhanced LDAP integration](/docs/grafana//setup-grafana/configure-security/configure-authentication/enhanced-ldap/), you can set up active LDAP synchronization. ### SAML authentication -[SAML authentication]({{< relref "../setup-grafana/configure-security/configure-authentication/saml" >}}) enables users to authenticate with single sign-on services that use Security Assertion Markup Language (SAML). +[SAML authentication](/docs/grafana//setup-grafana/configure-security/configure-authentication/saml/) enables users to authenticate with single sign-on services that use Security Assertion Markup Language (SAML). ### Protected roles -With [protected roles]({{< relref "../setup-grafana/configure-security/configure-authentication#protected-roles" >}}), you can define user roles that are exempt from being converted from one authentication type to another when changing auth providers. +With [protected roles](/docs/grafana//setup-grafana/configure-security/configure-authentication/#protected-roles), you can define user roles that are exempt from being converted from one authentication type to another when changing auth providers. ## Enterprise features Grafana Enterprise adds the following features: -- [Role-based access control]({{< relref "../administration/roles-and-permissions/access-control" >}}) to control access with role-based permissions. -- [Data source permissions]({{< relref "../administration/data-source-management#data-source-permissions" >}}) to restrict query access to specific teams and users. -- [Data source query and resource caching]({{< relref "../administration/data-source-management#query-and-resource-caching" >}}) to temporarily store query results in Grafana to reduce data source load and rate limiting. -- [Reporting]({{< relref "../dashboards/create-reports" >}}) to generate a PDF report from any dashboard and set up a schedule to have it emailed to whomever you choose. -- [Export dashboard as PDF]({{< relref "../dashboards/share-dashboards-panels#export-a-dashboard-as-pdf" >}}) -- [Custom branding]({{< relref "../setup-grafana/configure-grafana/configure-custom-branding" >}}) to customize Grafana from the brand and logo to the footer links. -- [Usage insights]({{< relref "../dashboards/assess-dashboard-usage" >}}) to understand how your Grafana instance is used. -- [Recorded queries]({{< relref "../administration/recorded-queries" >}}) to see trends over time for your data sources. -- [Vault integration]({{< relref "../setup-grafana/configure-security/configure-database-encryption#encrypting-your-database-with-a-key-from-a-key-management-service-kms" >}}) to manage your configuration or provisioning secrets with Vault. -- [Auditing]({{< relref "../setup-grafana/configure-security/audit-grafana" >}}) tracks important changes to your Grafana instance to help you manage and mitigate suspicious activity and meet compliance requirements. -- [Request security]({{< relref "../setup-grafana/configure-security/configure-request-security" >}}) makes it possible to restrict outgoing requests from the Grafana server. -- [Settings updates at runtime]({{< relref "../setup-grafana/configure-grafana/settings-updates-at-runtime" >}}) allows you to update Grafana settings at runtime without requiring a restart. +- [Role-based access control](https://grafana.com/docs/grafana//administration/roles-and-permissions/access-control/) to control access with role-based permissions. +- [Data source permissions](https://grafana.com/docs/grafana//administration/data-source-management/#data-source-permissions) to restrict query access to specific teams and users. +- [Data source query and resource caching](https://grafana.com/docs/grafana//administration/data-source-management/#query-and-resource-caching) to temporarily store query results in Grafana to reduce data source load and rate limiting. +- [Reporting](https://grafana.com/docs/grafana//dashboards/create-reports/) to generate a PDF report from any dashboard and set up a schedule to have it emailed to whomever you choose. +- [Export dashboard as PDF](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/#export-a-dashboard-as-pdf) +- [Custom branding](https://grafana.com/docs/grafana//setup-grafana/configure-grafana/configure-custom-branding/) to customize Grafana from the brand and logo to the footer links. +- [Usage insights](https://grafana.com/docs/grafana//dashboards/assess-dashboard-usage/) to understand how your Grafana instance is used. +- [Recorded queries](https://grafana.com/docs/grafana//administration/recorded-queries/) to see trends over time for your data sources. +- [Vault integration](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-database-encryption/#encrypting-your-database-with-a-key-from-a-key-management-service-kms) to manage your configuration or provisioning secrets with Vault. +- [Auditing](https://grafana.com/docs/grafana//setup-grafana/configure-security/audit-grafana/) tracks important changes to your Grafana instance to help you manage and mitigate suspicious activity and meet compliance requirements. +- [Request security](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-request-security/) makes it possible to restrict outgoing requests from the Grafana server. +- [Settings updates at runtime](https://grafana.com/docs/grafana//setup-grafana/configure-grafana/settings-updates-at-runtime/) allows you to update Grafana settings at runtime without requiring a restart. ## Enterprise data sources From b888f03d3f4f1d0774fb34e45c405ddca99833c8 Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Thu, 20 Feb 2025 11:51:40 +0100 Subject: [PATCH 11/11] Dynamic dashboards: Add tests for RowLayoutSerializer (#101030) Add tests for RowLayoutSerializer --- .../RowsLayoutSerializer.test.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts new file mode 100644 index 00000000000..ffd9241e0f5 --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts @@ -0,0 +1,100 @@ +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; + +import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager'; +import { ResponsiveGridLayoutManager } from '../../scene/layout-responsive-grid/ResponsiveGridLayoutManager'; +import { RowsLayoutManager } from '../../scene/layout-rows/RowsLayoutManager'; + +import { RowsLayoutSerializer } from './RowsLayoutSerializer'; + +describe('deserialization', () => { + it('should deserialize rows layout with default grid child', () => { + const layout: DashboardV2Spec['layout'] = { + kind: 'RowsLayout', + spec: { + rows: [ + { + kind: 'RowsLayoutRow', + spec: { + title: 'Row 1', + collapsed: false, + layout: { kind: 'GridLayout', spec: { items: [] } }, + }, + }, + ], + }, + }; + const serializer = new RowsLayoutSerializer(); + const deserialized = serializer.deserialize(layout, {}, false); + expect(deserialized).toBeInstanceOf(RowsLayoutManager); + expect(deserialized.state.rows[0].state.layout).toBeInstanceOf(DefaultGridLayoutManager); + }); + + it('should deserialize rows layout with responsive grid child', () => { + const layout: DashboardV2Spec['layout'] = { + kind: 'RowsLayout', + spec: { + rows: [ + { + kind: 'RowsLayoutRow', + spec: { + title: 'Row 1', + collapsed: false, + layout: { kind: 'ResponsiveGridLayout', spec: { row: '', col: '', items: [] } }, + }, + }, + ], + }, + }; + const serializer = new RowsLayoutSerializer(); + const deserialized = serializer.deserialize(layout, {}, false); + expect(deserialized).toBeInstanceOf(RowsLayoutManager); + expect(deserialized.state.rows[0].state.layout).toBeInstanceOf(ResponsiveGridLayoutManager); + }); + + it('should handle multiple rows with different layouts', () => { + const layout: DashboardV2Spec['layout'] = { + kind: 'RowsLayout', + spec: { + rows: [ + { + kind: 'RowsLayoutRow', + spec: { + title: 'Row 1', + collapsed: false, + layout: { kind: 'ResponsiveGridLayout', spec: { row: '', col: '', items: [] } }, + }, + }, + { + kind: 'RowsLayoutRow', + spec: { + title: 'Row 2', + collapsed: true, + layout: { kind: 'GridLayout', spec: { items: [] } }, + }, + }, + ], + }, + }; + const serializer = new RowsLayoutSerializer(); + const deserialized = serializer.deserialize(layout, {}, false); + expect(deserialized).toBeInstanceOf(RowsLayoutManager); + expect(deserialized.state.rows).toHaveLength(2); + expect(deserialized.state.rows[0].state.layout).toBeInstanceOf(ResponsiveGridLayoutManager); + expect(deserialized.state.rows[1].state.layout).toBeInstanceOf(DefaultGridLayoutManager); + expect(deserialized.state.rows[0].state.isCollapsed).toBe(false); + expect(deserialized.state.rows[1].state.isCollapsed).toBe(true); + }); + + it('should handle 0 rows', () => { + const layout: DashboardV2Spec['layout'] = { + kind: 'RowsLayout', + spec: { + rows: [], + }, + }; + const serializer = new RowsLayoutSerializer(); + const deserialized = serializer.deserialize(layout, {}, false); + expect(deserialized).toBeInstanceOf(RowsLayoutManager); + expect(deserialized.state.rows).toHaveLength(0); + }); +});