From 00b51c04227bb1939f57fff401198a69096252b4 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Tue, 17 Dec 2024 16:18:05 +0200 Subject: [PATCH 01/26] I18n: Download translations from Crowdin (#98107) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/de-DE/grafana.json | 7 ------- public/locales/es-ES/grafana.json | 7 ------- public/locales/fr-FR/grafana.json | 7 ------- public/locales/pt-BR/grafana.json | 7 ------- public/locales/zh-Hans/grafana.json | 7 ------- 5 files changed, 35 deletions(-) diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index eae60399f54..e4efd8bd946 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -2342,12 +2342,6 @@ "list-label": "Navigation", "open": "", "undock": "Menü abdocken" - }, - "toolbar": { - "close-menu": "Menü schließen", - "enable-kiosk": "Kiosk-Modus aktivieren", - "open-menu": "Menü öffnen", - "toggle-search-bar": "Obere Suchleiste umschalten" } }, "news": { @@ -3041,7 +3035,6 @@ "mode": "Modus", "mode-kiosk": "Kiosk", "mode-normal": "Normal", - "mode-tv": "TM", "title": "Wiedergabeliste teilen" }, "shared": { diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 229223c5efa..39f6b0cc7f3 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -2342,12 +2342,6 @@ "list-label": "Navegación", "open": "", "undock": "Desanclar el menú" - }, - "toolbar": { - "close-menu": "Cerrar menú", - "enable-kiosk": "Activar el modo de quiosco", - "open-menu": "Abrir menú", - "toggle-search-bar": "Activar o desactivar la barra de búsqueda superior" } }, "news": { @@ -3041,7 +3035,6 @@ "mode": "Modo", "mode-kiosk": "Quiosco", "mode-normal": "Normal", - "mode-tv": "TV", "title": "Compartir lista de reproducción" }, "shared": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 0624603c950..3085400970a 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -2342,12 +2342,6 @@ "list-label": "Navigation", "open": "", "undock": "Ancrer le menu" - }, - "toolbar": { - "close-menu": "Fermer le menu", - "enable-kiosk": "Activer le mode kiosque", - "open-menu": "Ouvrir le menu", - "toggle-search-bar": "Afficher/Masquer la barre de recherche" } }, "news": { @@ -3041,7 +3035,6 @@ "mode": "Mode", "mode-kiosk": "Kiosque", "mode-normal": "Normal", - "mode-tv": "Téléviseur", "title": "Partager la playlist" }, "shared": { diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 52c17b75e5b..5dcde5e4794 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -2342,12 +2342,6 @@ "list-label": "Navegação", "open": "", "undock": "Desacoplar menu" - }, - "toolbar": { - "close-menu": "Fechar menu", - "enable-kiosk": "Ativar modo de quiosque", - "open-menu": "Abrir menu", - "toggle-search-bar": "Alternar barra de busca superior" } }, "news": { @@ -3041,7 +3035,6 @@ "mode": "Modo", "mode-kiosk": "Quiosque", "mode-normal": "Normal", - "mode-tv": "TV", "title": "Compartilhar lista de reprodução" }, "shared": { diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index f19544d979e..73a4b24d14e 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -2332,12 +2332,6 @@ "list-label": "导航", "open": "", "undock": "取消停靠菜单" - }, - "toolbar": { - "close-menu": "关闭菜单", - "enable-kiosk": "启用 kiosk 模式", - "open-menu": "打开菜单", - "toggle-search-bar": "切换顶部搜索栏" } }, "news": { @@ -3027,7 +3021,6 @@ "mode": "模式", "mode-kiosk": "Kiosk", "mode-normal": "正常", - "mode-tv": "电视", "title": "共享播放列表" }, "shared": { From eb38a63559dfc91769f6728c76a32aced3765267 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Tue, 17 Dec 2024 17:19:40 +0300 Subject: [PATCH 02/26] K8s/Folder: Show parent folder information (#97938) --- pkg/apis/folder/v0alpha1/types.go | 10 ++- .../folder/v0alpha1/zz_generated.openapi.go | 20 ++++- pkg/registry/apis/folders/register.go | 10 +-- pkg/registry/apis/folders/sub_parents.go | 74 +++++++++++++------ 4 files changed, 81 insertions(+), 33 deletions(-) diff --git a/pkg/apis/folder/v0alpha1/types.go b/pkg/apis/folder/v0alpha1/types.go index c0bbb12ba40..ceaaaf1233b 100644 --- a/pkg/apis/folder/v0alpha1/types.go +++ b/pkg/apis/folder/v0alpha1/types.go @@ -45,14 +45,20 @@ type FolderInfoList struct { // FolderInfo briefly describes a folder -- unlike a folder resource, // this is a partial record of the folder metadata used for navigating parents and children type FolderInfo struct { - // UID is the unique identifier for a folder (and the k8s name) - UID string `json:"uid"` + // Name is the k8s name (eg, the unique identifier) for a folder + Name string `json:"name"` // Title is the display value Title string `json:"title"` + // The folder description + Description string `json:"description,omitempty"` + // The parent folder UID Parent string `json:"parent,omitempty"` + + // This folder does not resolve + Detached bool `json:"detached,omitempty"` } // Access control information for the current user diff --git a/pkg/apis/folder/v0alpha1/zz_generated.openapi.go b/pkg/apis/folder/v0alpha1/zz_generated.openapi.go index 25a12cef515..c3c9f74bfc4 100644 --- a/pkg/apis/folder/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/folder/v0alpha1/zz_generated.openapi.go @@ -170,9 +170,9 @@ func schema_pkg_apis_folder_v0alpha1_FolderInfo(ref common.ReferenceCallback) co Description: "FolderInfo briefly describes a folder -- unlike a folder resource, this is a partial record of the folder metadata used for navigating parents and children", Type: []string{"object"}, Properties: map[string]spec.Schema{ - "uid": { + "name": { SchemaProps: spec.SchemaProps{ - Description: "UID is the unique identifier for a folder (and the k8s name)", + Description: "Name is the k8s name (eg, the unique identifier) for a folder", Default: "", Type: []string{"string"}, Format: "", @@ -186,6 +186,13 @@ func schema_pkg_apis_folder_v0alpha1_FolderInfo(ref common.ReferenceCallback) co Format: "", }, }, + "description": { + SchemaProps: spec.SchemaProps{ + Description: "The folder description", + Type: []string{"string"}, + Format: "", + }, + }, "parent": { SchemaProps: spec.SchemaProps{ Description: "The parent folder UID", @@ -193,8 +200,15 @@ func schema_pkg_apis_folder_v0alpha1_FolderInfo(ref common.ReferenceCallback) co Format: "", }, }, + "detached": { + SchemaProps: spec.SchemaProps{ + Description: "This folder does not resolve", + Type: []string{"boolean"}, + Format: "", + }, + }, }, - Required: []string{"uid", "title"}, + Required: []string{"name", "title"}, }, }, } diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go index f4f36f26d9c..dd4a4d3c826 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -126,11 +126,6 @@ func (b *FolderAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.API storage := map[string]rest.Storage{} storage[resourceInfo.StoragePath()] = legacyStore - storage[resourceInfo.StoragePath("parents")] = &subParentsREST{b.folderSvc} - storage[resourceInfo.StoragePath("access")] = &subAccessREST{b.folderSvc} - storage[resourceInfo.StoragePath("counts")] = &subCountREST{searcher: b.searcher} - - // enable dual writer if optsGetter != nil && dualWriteBuilder != nil { store, err := grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) if err != nil { @@ -141,6 +136,11 @@ func (b *FolderAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.API return err } } + storage[resourceInfo.StoragePath("parents")] = &subParentsREST{ + getter: storage[resourceInfo.StoragePath()].(rest.Getter), // Get the parents + } + storage[resourceInfo.StoragePath("counts")] = &subCountREST{searcher: b.searcher} + storage[resourceInfo.StoragePath("access")] = &subAccessREST{b.folderSvc} apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage b.storage = storage[resourceInfo.StoragePath()].(grafanarest.Storage) diff --git a/pkg/registry/apis/folders/sub_parents.go b/pkg/registry/apis/folders/sub_parents.go index f7761d075b1..f532b945f49 100644 --- a/pkg/registry/apis/folders/sub_parents.go +++ b/pkg/registry/apis/folders/sub_parents.go @@ -2,18 +2,20 @@ package folders import ( "context" + "fmt" "net/http" + "slices" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/rest" + "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" - "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" - "github.com/grafana/grafana/pkg/services/folder" ) type subParentsREST struct { - service folder.Service + getter rest.Getter } var _ = rest.Connecter(&subParentsREST{}) @@ -43,32 +45,58 @@ func (r *subParentsREST) NewConnectOptions() (runtime.Object, bool, string) { } func (r *subParentsREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + obj, err := r.getter.Get(ctx, name, &metav1.GetOptions{}) + if err != nil { + return nil, err + } + folder, ok := obj.(*v0alpha1.Folder) + if !ok { + return nil, fmt.Errorf("expecting folder, found: %T", folder) + } + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - ns, err := request.NamespaceInfoFrom(ctx, true) - if err != nil { - responder.Error(err) - return - } - - parents, err := r.service.GetParents(ctx, folder.GetParentsQuery{ - UID: name, - OrgID: ns.OrgID, - }) - if err != nil { - responder.Error(err) - return - } - info := &v0alpha1.FolderInfoList{ - Items: make([]v0alpha1.FolderInfo, 0), + Items: []v0alpha1.FolderInfo{}, } - for _, parent := range parents { + for folder != nil { + parent := "" + meta, _ := utils.MetaAccessor(folder) + if meta != nil { + parent = meta.GetFolder() + } info.Items = append(info.Items, v0alpha1.FolderInfo{ - UID: parent.UID, - Title: parent.Title, - Parent: parent.ParentUID, + Name: folder.Name, + Title: folder.Spec.Title, + Description: folder.Spec.Description, + Parent: parent, }) + if parent == "" { + break + } + + obj, err = r.getter.Get(ctx, parent, &metav1.GetOptions{}) + if err != nil { + info.Items = append(info.Items, v0alpha1.FolderInfo{ + Name: parent, + Detached: true, + Description: err.Error(), + }) + break + } + + folder, ok = obj.(*v0alpha1.Folder) + if !ok { + info.Items = append(info.Items, v0alpha1.FolderInfo{ + Name: parent, + Detached: true, + Description: fmt.Sprintf("expected folder, found: %T", obj), + }) + break + } } + + // Start from the root + slices.Reverse(info.Items) responder.Object(http.StatusOK, info) }), nil } From 4477cad1ba05c8938771a82df4c98a4646643707 Mon Sep 17 00:00:00 2001 From: jackyin Date: Tue, 17 Dec 2024 22:20:18 +0800 Subject: [PATCH 03/26] Org: Fix redirection logic to work consistently (#96521) fix unexpected redirection --- pkg/middleware/org_redirect.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/middleware/org_redirect.go b/pkg/middleware/org_redirect.go index 36a1081f415..c4d5623d69e 100644 --- a/pkg/middleware/org_redirect.go +++ b/pkg/middleware/org_redirect.go @@ -50,7 +50,7 @@ func OrgRedirect(cfg *setting.Cfg, userSvc user.Service) web.Handler { qs = fmt.Sprintf("%s&kiosk", urlParams.Encode()) } - newURL := fmt.Sprintf("%s%s?%s", cfg.AppURL, strings.TrimPrefix(c.Req.URL.Path, "/"), qs) + newURL := fmt.Sprintf("%s%s?%s", cfg.AppSubURL, strings.TrimPrefix(c.Req.URL.Path, "/"), qs) c.Redirect(newURL, 302) } From 918210dda107e3ce45ede5ad946c166713d441d6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Dec 2024 16:20:52 +0200 Subject: [PATCH 04/26] Update scenes to v5.34.0 (#98108) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index 3cc9d6b742a..a6317b1afb7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3830,10 +3830,10 @@ __metadata: linkType: soft "@grafana/scenes-react@npm:^5.32.0": - version: 5.32.0 - resolution: "@grafana/scenes-react@npm:5.32.0" + version: 5.34.0 + resolution: "@grafana/scenes-react@npm:5.34.0" dependencies: - "@grafana/scenes": "npm:5.32.0" + "@grafana/scenes": "npm:5.34.0" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3844,13 +3844,13 @@ __metadata: "@grafana/ui": ^11.0.0 react: ^18.0.0 react-dom: ^18.0.0 - checksum: 10/92ffbb25e945a267ef24b20d4f512e0aa211be4bd1c8e9d81fea337382e5b5316c4accb2a4055119839f6ac4f5adaa4a57e56e3d45372d80ca09ce83779b1796 + checksum: 10/3aa132d2bce7e2615cc26c73b1f6988176e7d2c77826589237bb8322bc4d180381d9f19742ad84f788c4dbc1fa3ccbafdeaa6c704b6400796069837486cb03cf languageName: node linkType: hard -"@grafana/scenes@npm:5.32.0, @grafana/scenes@npm:^5.32.0": - version: 5.32.0 - resolution: "@grafana/scenes@npm:5.32.0" +"@grafana/scenes@npm:5.34.0, @grafana/scenes@npm:^5.32.0": + version: 5.34.0 + resolution: "@grafana/scenes@npm:5.34.0" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" @@ -3867,7 +3867,7 @@ __metadata: "@grafana/ui": ">=10.4" react: ^18.0.0 react-dom: ^18.0.0 - checksum: 10/e1c489bdf99101de562dd86cecee9e0ac85b8ccd044fda732706da98900bd9216e88dfdb1d2b98b2c870a9f37b7958b3e31f4b06dea5825bb9f930f6aa724512 + checksum: 10/a0b8f9d271a5db05b54345642622546549da1f4a7e36007980e3a4f66513ea98aecbff6403f5ef00cd430609587b83a2807404b4c7d4da670e5b8cc44ff57cb4 languageName: node linkType: hard From 4c8d14270d5fb3cd2456ecf9949dc1afbcb80edc Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Tue, 17 Dec 2024 15:22:21 +0100 Subject: [PATCH 05/26] Docs: Read on the dashboard/datasource is required to view insights (#98111) * Docs: Read on the dashboard/datasource is required to view insights * ',' --- .../access-control/custom-role-actions-scopes/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md b/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md index 8a424bc3c67..ff097506b2e 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md @@ -74,7 +74,7 @@ The following list contains role-based access control actions. | `banners:write` | None | Create [announcement banners](/docs/grafana-cloud/whats-new/2024-09-10-announcement-banner/). | | `dashboards:create` |
  • `folders:*`
  • `folders:uid:*`
| Create dashboards in one or more folders and their subfolders. | | `dashboards:delete` |
  • `dashboards:*`
  • `dashboards:uid:*`
  • `folders:*`
  • `folders:uid:*`
| Delete one or more dashboards. | -| `dashboards.insights:read` | None | Read dashboard insights data and see presence indicators. | +| `dashboards.insights:read` | None | Read dashboard insights data and see presence indicators. To view insights, `dashboards:read` on the dashboard is also needed. | | `dashboards.permissions:read` |
  • `dashboards:*`
  • `dashboards:uid:*`
  • `folders:*`
  • `folders:uid:*`
| Read permissions for one or more dashboards. | | `dashboards.permissions:write` |
  • `dashboards:*`
  • `dashboards:uid:*`
  • `folders:*`
  • `folders:uid:*`
| Update permissions for one or more dashboards. | | `dashboards:read` |
  • `dashboards:*`
  • `dashboards:uid:*`
  • `folders:*`
  • `folders:uid:*`
| Read one or more dashboards. | @@ -86,7 +86,7 @@ The following list contains role-based access control actions. | `datasources:delete` |
  • `datasources:*`
  • `datasources:uid:*`
| Delete data sources. | | `datasources:explore` | None | Enable access to the **Explore** tab. | | `datasources.id:read` |
  • `datasources:*`
  • `datasources:uid:*`
| Read data source IDs. | -| `datasources.insights:read` | None | Read data sources insights data. | +| `datasources.insights:read` | None | Read data sources insights data. To view insights, `datasources:read` on the datasource is also needed. | | `datasources.permissions:read` |
  • `datasources:*`
  • `datasources:uid:*`
| List data source permissions. | | `datasources.permissions:write` |
  • `datasources:*`
  • `datasources:uid:*`
| Update data source permissions. | | `datasources:query` |
  • `datasources:*`
  • `datasources:uid:*`
| Query data sources. | From 5ded891faaba1aecabe0b28622ffc6c8479df19a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Dec 2024 14:40:11 +0000 Subject: [PATCH 06/26] Update dependency yaml to v2.6.1 (#98105) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index a6317b1afb7..f9072eda341 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31281,11 +31281,11 @@ __metadata: linkType: hard "yaml@npm:^2.0.0, yaml@npm:^2.3.4": - version: 2.5.1 - resolution: "yaml@npm:2.5.1" + version: 2.6.1 + resolution: "yaml@npm:2.6.1" bin: yaml: bin.mjs - checksum: 10/0eecb679db75ea6a989ad97715a9fa5d946972945aa6aa7d2175bca66c213b5564502ccb1cdd04b1bf816ee38b5c43e4e2fda3ff6f5e09da24dabb51ae92c57d + checksum: 10/cf412f03a33886db0a3aac70bb4165588f4c5b3c6f8fc91520b71491e5537800b6c2c73ed52015617f6e191eb4644c73c92973960a1999779c62a200ee4c231d languageName: node linkType: hard From 8b8606326a6117298cfa79860e5af826fcbf9904 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Dec 2024 14:46:18 +0000 Subject: [PATCH 07/26] Update typescript-eslint monorepo to v8.18.1 (#98109) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 4 +- packages/grafana-prometheus/package.json | 4 +- yarn.lock | 192 ++++++++--------------- 3 files changed, 68 insertions(+), 132 deletions(-) diff --git a/package.json b/package.json index 55bd4e3ce71..564b3d3bb75 100644 --- a/package.json +++ b/package.json @@ -155,8 +155,8 @@ "@types/webpack-assets-manifest": "^5", "@types/webpack-env": "^1.18.4", "@types/yargs": "17.0.33", - "@typescript-eslint/eslint-plugin": "8.9.0", - "@typescript-eslint/parser": "8.9.0", + "@typescript-eslint/eslint-plugin": "8.18.1", + "@typescript-eslint/parser": "8.18.1", "autoprefixer": "10.4.20", "babel-loader": "9.2.1", "blob-polyfill": "9.0.20240710", diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 2cea7f0f7ab..ecf4a9d725f 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -101,8 +101,8 @@ "@types/react-window": "1.8.8", "@types/semver": "7.5.8", "@types/uuid": "9.0.8", - "@typescript-eslint/eslint-plugin": "8.9.0", - "@typescript-eslint/parser": "8.9.0", + "@typescript-eslint/eslint-plugin": "8.18.1", + "@typescript-eslint/parser": "8.18.1", "copy-webpack-plugin": "12.0.2", "css-loader": "7.1.2", "esbuild": "0.24.0", diff --git a/yarn.lock b/yarn.lock index f9072eda341..d19b93079a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3686,8 +3686,8 @@ __metadata: "@types/react-window": "npm:1.8.8" "@types/semver": "npm:7.5.8" "@types/uuid": "npm:9.0.8" - "@typescript-eslint/eslint-plugin": "npm:8.9.0" - "@typescript-eslint/parser": "npm:8.9.0" + "@typescript-eslint/eslint-plugin": "npm:8.18.1" + "@typescript-eslint/parser": "npm:8.18.1" copy-webpack-plugin: "npm:12.0.2" css-loader: "npm:7.1.2" d3: "npm:7.9.0" @@ -10146,15 +10146,15 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.9.0": - version: 8.9.0 - resolution: "@typescript-eslint/eslint-plugin@npm:8.9.0" +"@typescript-eslint/eslint-plugin@npm:8.18.1": + version: 8.18.1 + resolution: "@typescript-eslint/eslint-plugin@npm:8.18.1" dependencies: "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:8.9.0" - "@typescript-eslint/type-utils": "npm:8.9.0" - "@typescript-eslint/utils": "npm:8.9.0" - "@typescript-eslint/visitor-keys": "npm:8.9.0" + "@typescript-eslint/scope-manager": "npm:8.18.1" + "@typescript-eslint/type-utils": "npm:8.18.1" + "@typescript-eslint/utils": "npm:8.18.1" + "@typescript-eslint/visitor-keys": "npm:8.18.1" graphemer: "npm:^1.4.0" ignore: "npm:^5.3.1" natural-compare: "npm:^1.4.0" @@ -10162,28 +10162,24 @@ __metadata: peerDependencies: "@typescript-eslint/parser": ^8.0.0 || ^8.0.0-alpha.0 eslint: ^8.57.0 || ^9.0.0 - peerDependenciesMeta: - typescript: - optional: true - checksum: 10/c1858656d7ab3d37674759c838422d8a1b7540e54a25c67c7508c38ee76594a98e8f1f269749f08700f93a7a425e0dca6eb6d031b36539c537e10a32edb4975c + typescript: ">=4.8.4 <5.8.0" + checksum: 10/ec061a9c64477260d1ef0fc6283d8754838181e17aa90b3b8b9a70936a2ca4bae11607070917a7701e13f5301ced2b6da4b4b6e5cf525c484f97481e540b5111 languageName: node linkType: hard -"@typescript-eslint/parser@npm:8.9.0": - version: 8.9.0 - resolution: "@typescript-eslint/parser@npm:8.9.0" +"@typescript-eslint/parser@npm:8.18.1": + version: 8.18.1 + resolution: "@typescript-eslint/parser@npm:8.18.1" dependencies: - "@typescript-eslint/scope-manager": "npm:8.9.0" - "@typescript-eslint/types": "npm:8.9.0" - "@typescript-eslint/typescript-estree": "npm:8.9.0" - "@typescript-eslint/visitor-keys": "npm:8.9.0" + "@typescript-eslint/scope-manager": "npm:8.18.1" + "@typescript-eslint/types": "npm:8.18.1" + "@typescript-eslint/typescript-estree": "npm:8.18.1" + "@typescript-eslint/visitor-keys": "npm:8.18.1" debug: "npm:^4.3.4" peerDependencies: eslint: ^8.57.0 || ^9.0.0 - peerDependenciesMeta: - typescript: - optional: true - checksum: 10/6f73af7782856b292b37e43dde83c5babbbdae28da1a4fed764474a9ccbbfcce25903cedde82d6847ad53e0c1a7c2ed53f087b281e6f1408a064498169c0e2d1 + typescript: ">=4.8.4 <5.8.0" + checksum: 10/09a601ef8b837962e5bb2687358520f337f9d0bbac5c6d5e159654faa5caaffb24d990e8d6bc4dc51ff5008dd9e182315c35bc5e9e3789090ccef8b8040e7659 languageName: node linkType: hard @@ -10197,38 +10193,28 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.14.0": - version: 8.14.0 - resolution: "@typescript-eslint/scope-manager@npm:8.14.0" +"@typescript-eslint/scope-manager@npm:8.18.1": + version: 8.18.1 + resolution: "@typescript-eslint/scope-manager@npm:8.18.1" dependencies: - "@typescript-eslint/types": "npm:8.14.0" - "@typescript-eslint/visitor-keys": "npm:8.14.0" - checksum: 10/48ff44a790254b5a98c17bf15176fbdc1408b58eb3ccd8eda9c5707811786de25e1bccc5c490dcc05cbd34b685e162ee4e92b28f57b071c522274fa97f23c98c + "@typescript-eslint/types": "npm:8.18.1" + "@typescript-eslint/visitor-keys": "npm:8.18.1" + checksum: 10/14f7c09924c3a006b20752e5204b33c2b6974fc00bea16c23f471e65f2fb089fcbd3fb5296bcfd6727ac95c32ba24ebb15ba84fbf1deadc17b4cc5ca7f41c72a languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.9.0": - version: 8.9.0 - resolution: "@typescript-eslint/scope-manager@npm:8.9.0" +"@typescript-eslint/type-utils@npm:8.18.1": + version: 8.18.1 + resolution: "@typescript-eslint/type-utils@npm:8.18.1" dependencies: - "@typescript-eslint/types": "npm:8.9.0" - "@typescript-eslint/visitor-keys": "npm:8.9.0" - checksum: 10/44dfb640113e8be2f5d25034f5657a9609ee06082b817dc24116c5e1d7a708ca31e8eedcc47f7d309def2ce63be662d1d0a37a1c7bdc7345968a31d04c0a2377 - languageName: node - linkType: hard - -"@typescript-eslint/type-utils@npm:8.9.0": - version: 8.9.0 - resolution: "@typescript-eslint/type-utils@npm:8.9.0" - dependencies: - "@typescript-eslint/typescript-estree": "npm:8.9.0" - "@typescript-eslint/utils": "npm:8.9.0" + "@typescript-eslint/typescript-estree": "npm:8.18.1" + "@typescript-eslint/utils": "npm:8.18.1" debug: "npm:^4.3.4" ts-api-utils: "npm:^1.3.0" - peerDependenciesMeta: - typescript: - optional: true - checksum: 10/aaeb465ed57d140bc0d9a8b81a474eff5d1c63d99479828b4eb83a1a626dcb2b1377052a971be5b4d094d6adcf1cf8e33c41ee13369bd71aed0f9cd9f3528c8a + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <5.8.0" + checksum: 10/cde53d05f4ca6e172239918cba2b560b9f837aa1fc7d5220784b1a6af9c8c525db020a5160822087e320305492fe359b7fb191420789b5f1e47a01e0cda21ac9 languageName: node linkType: hard @@ -10239,17 +10225,10 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/types@npm:8.14.0, @typescript-eslint/types@npm:^8.9.0": - version: 8.14.0 - resolution: "@typescript-eslint/types@npm:8.14.0" - checksum: 10/1924aef8efdf5399d6cc9ef3a5307fda39b1a2be129ab8cb24a46dc0a37156230e77f2809ab709d5d0a43891b6ffd67ce45292724e8f8164ac19e1786c5f4644 - languageName: node - linkType: hard - -"@typescript-eslint/types@npm:8.9.0": - version: 8.9.0 - resolution: "@typescript-eslint/types@npm:8.9.0" - checksum: 10/4d087153605ec23c980f9bc807b122edefff828e0c3b52ef531f4b8e1d30078c39f95e84019370a395bf97eed0d7886cc50b8cd545c287f8a2a21b301272377a +"@typescript-eslint/types@npm:8.18.1, @typescript-eslint/types@npm:^8.9.0": + version: 8.18.1 + resolution: "@typescript-eslint/types@npm:8.18.1" + checksum: 10/57a6141ba17be929291a644991f3a76f94fce330376f6a079decb20fb53378d636ad6878f8f9b6fcb8244cf1ca8b118f9e8901ae04cf3de2aa9f9ff57791d97a languageName: node linkType: hard @@ -10271,55 +10250,36 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.14.0": - version: 8.14.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.14.0" +"@typescript-eslint/typescript-estree@npm:8.18.1": + version: 8.18.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.18.1" dependencies: - "@typescript-eslint/types": "npm:8.14.0" - "@typescript-eslint/visitor-keys": "npm:8.14.0" + "@typescript-eslint/types": "npm:8.18.1" + "@typescript-eslint/visitor-keys": "npm:8.18.1" debug: "npm:^4.3.4" fast-glob: "npm:^3.3.2" is-glob: "npm:^4.0.3" minimatch: "npm:^9.0.4" semver: "npm:^7.6.0" ts-api-utils: "npm:^1.3.0" - peerDependenciesMeta: - typescript: - optional: true - checksum: 10/b0b9f228071b6338dbf5e2ac52848fa6af630e8d84d4102e1cccaae67114f2bff82bd027af2818e3ad778668e3c3d4a2fb31b7f4c8a9796295e5aa87903fb313 + peerDependencies: + typescript: ">=4.8.4 <5.8.0" + checksum: 10/8ecc1b50b9fc32116eee1b3b00f3fb29cf18026c0bbb50ab5f6e01db58ef62b8ac01824f2950f132479be6e1b82466a2bfd1e2cb4525aa8dbce4c27fc2494cfc languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.9.0": - version: 8.9.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.9.0" - dependencies: - "@typescript-eslint/types": "npm:8.9.0" - "@typescript-eslint/visitor-keys": "npm:8.9.0" - debug: "npm:^4.3.4" - fast-glob: "npm:^3.3.2" - is-glob: "npm:^4.0.3" - minimatch: "npm:^9.0.4" - semver: "npm:^7.6.0" - ts-api-utils: "npm:^1.3.0" - peerDependenciesMeta: - typescript: - optional: true - checksum: 10/855b433f24fad5d6791c16510d035ded31ccfd17235b45f4dcb7fa89ed57268e4bf4bf79311c5323037e6243da506b2edcb113aa51339291efb344b6d8035b1a - languageName: node - linkType: hard - -"@typescript-eslint/utils@npm:8.9.0": - version: 8.9.0 - resolution: "@typescript-eslint/utils@npm:8.9.0" +"@typescript-eslint/utils@npm:8.18.1, @typescript-eslint/utils@npm:^6.0.0 || ^7.0.0 || ^8.0.0, @typescript-eslint/utils@npm:^8.13.0, @typescript-eslint/utils@npm:^8.9.0": + version: 8.18.1 + resolution: "@typescript-eslint/utils@npm:8.18.1" dependencies: "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:8.9.0" - "@typescript-eslint/types": "npm:8.9.0" - "@typescript-eslint/typescript-estree": "npm:8.9.0" + "@typescript-eslint/scope-manager": "npm:8.18.1" + "@typescript-eslint/types": "npm:8.18.1" + "@typescript-eslint/typescript-estree": "npm:8.18.1" peerDependencies: eslint: ^8.57.0 || ^9.0.0 - checksum: 10/84efd10d6aa212103615cf52211a79f1ca02dc4fbf2dbb3a8d2aa49cd19f582b04c219ee98ed1ab77a503f967d82ce56521b1663359ff3e7faaa1f8798c19697 + typescript: ">=4.8.4 <5.8.0" + checksum: 10/7b33d2ac273ad606a3dcb776bcf02c901812952550cdc93d4ece272b3b0e5d2a4e05fa92f9bd466f4a296ddd5992902d3b6623aa1c29d09e8e392897103e42a8 languageName: node linkType: hard @@ -10341,20 +10301,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/utils@npm:^6.0.0 || ^7.0.0 || ^8.0.0, @typescript-eslint/utils@npm:^8.13.0, @typescript-eslint/utils@npm:^8.9.0": - version: 8.14.0 - resolution: "@typescript-eslint/utils@npm:8.14.0" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:8.14.0" - "@typescript-eslint/types": "npm:8.14.0" - "@typescript-eslint/typescript-estree": "npm:8.14.0" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - checksum: 10/6d3b2583c473b452dd8f978524802aabd275055f98d461cc71ee6a9424291f4481d2a3416a3f77b2458939dd38a39c0fd8e0c9b47915141c8409e63528a1216b - languageName: node - linkType: hard - "@typescript-eslint/visitor-keys@npm:5.62.0": version: 5.62.0 resolution: "@typescript-eslint/visitor-keys@npm:5.62.0" @@ -10365,23 +10311,13 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.14.0": - version: 8.14.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.14.0" +"@typescript-eslint/visitor-keys@npm:8.18.1": + version: 8.18.1 + resolution: "@typescript-eslint/visitor-keys@npm:8.18.1" dependencies: - "@typescript-eslint/types": "npm:8.14.0" - eslint-visitor-keys: "npm:^3.4.3" - checksum: 10/735cc9c2ce3235e543d03afe0de740022888e69ed9f5027564e1c959a3a087106bcf21b5b8d3ac872171c0a585744f0442b76fe6ba68341a735a4b4a15f52a46 - languageName: node - linkType: hard - -"@typescript-eslint/visitor-keys@npm:8.9.0": - version: 8.9.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.9.0" - dependencies: - "@typescript-eslint/types": "npm:8.9.0" - eslint-visitor-keys: "npm:^3.4.3" - checksum: 10/809097884b8c706f549d99bafa3e0958bd893b3deb190297110f2f1f9360e12064335c8f2df68f39be7d744d2032b5eb57b710c9671eb38f793877ab9364c731 + "@typescript-eslint/types": "npm:8.18.1" + eslint-visitor-keys: "npm:^4.2.0" + checksum: 10/00e88b1640a68c3afea08731395eb09a8216892248fee819cb7526e99093256743239d6b9e880a499f1c0ddfe2ffa4d1ad895d9e778b5d42e702d5880db1a594 languageName: node linkType: hard @@ -16000,7 +15936,7 @@ __metadata: languageName: node linkType: hard -"eslint-visitor-keys@npm:^3.3.0, eslint-visitor-keys@npm:^3.4.3": +"eslint-visitor-keys@npm:^3.3.0": version: 3.4.3 resolution: "eslint-visitor-keys@npm:3.4.3" checksum: 10/3f357c554a9ea794b094a09bd4187e5eacd1bc0d0653c3adeb87962c548e6a1ab8f982b86963ae1337f5d976004146536dcee5d0e2806665b193fbfbf1a9231b @@ -17717,8 +17653,8 @@ __metadata: "@types/webpack-assets-manifest": "npm:^5" "@types/webpack-env": "npm:^1.18.4" "@types/yargs": "npm:17.0.33" - "@typescript-eslint/eslint-plugin": "npm:8.9.0" - "@typescript-eslint/parser": "npm:8.9.0" + "@typescript-eslint/eslint-plugin": "npm:8.18.1" + "@typescript-eslint/parser": "npm:8.18.1" "@visx/event": "npm:3.3.0" "@visx/gradient": "npm:3.3.0" "@visx/group": "npm:3.3.0" From 03fcfd4e0c4f811646bc21645e995d33fbf040a9 Mon Sep 17 00:00:00 2001 From: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Tue, 17 Dec 2024 14:54:25 +0000 Subject: [PATCH 08/26] Fix: Explore Traces metrics queries in Safari (#98080) Fix explore traces in safari --- public/app/plugins/datasource/tempo/datasource.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index 616a26d6505..f844c1e0549 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -349,7 +349,7 @@ export class TempoDatasource extends DataSourceWithBackend): Observable => { - const validTargets = options.targets + handleTraceQlMetricsQuery( + options: DataQueryRequest, + targets: TempoQuery[] + ): Observable { + const validTargets = targets .filter((t) => t.query) .map( (t): TempoQuery => ({ ...t, query: this.applyVariables(t, options.scopedVars).query, queryType: 'traceql' }) @@ -607,7 +610,7 @@ export class TempoDatasource extends DataSourceWithBackend) => { reportInteraction('grafana_traces_metrics_summary_queried', { From 0d7303d536df0dcf426675300563557159144ac6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Dec 2024 15:30:35 +0000 Subject: [PATCH 09/26] Update visx to v3.12.0 (#98113) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 10 ++--- yarn.lock | 102 +++++++++++++++++++++++++-------------------------- 2 files changed, 56 insertions(+), 56 deletions(-) diff --git a/package.json b/package.json index 564b3d3bb75..7ec4265dabd 100644 --- a/package.json +++ b/package.json @@ -302,11 +302,11 @@ "@react-awesome-query-builder/ui": "6.6.4", "@reduxjs/toolkit": "2.5.0", "@testing-library/react-hooks": "^8.0.1", - "@visx/event": "3.3.0", - "@visx/gradient": "3.3.0", - "@visx/group": "3.3.0", - "@visx/shape": "3.5.0", - "@visx/tooltip": "3.3.0", + "@visx/event": "3.12.0", + "@visx/gradient": "3.12.0", + "@visx/group": "3.12.0", + "@visx/shape": "3.12.0", + "@visx/tooltip": "3.12.0", "@welldone-software/why-did-you-render": "8.0.3", "angular": "1.8.3", "angular-bindonce": "0.3.1", diff --git a/yarn.lock b/yarn.lock index d19b93079a0..19d6396800c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10321,9 +10321,9 @@ __metadata: languageName: node linkType: hard -"@visx/bounds@npm:3.3.0": - version: 3.3.0 - resolution: "@visx/bounds@npm:3.3.0" +"@visx/bounds@npm:3.12.0": + version: 3.12.0 + resolution: "@visx/bounds@npm:3.12.0" dependencies: "@types/react": "npm:*" "@types/react-dom": "npm:*" @@ -10331,82 +10331,82 @@ __metadata: peerDependencies: react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 react-dom: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 - checksum: 10/e682b343735f53d7eed04cc9e4bc40308583724faa0577f752449d59daea57030f05cb72a9711d7e7f46db932691fea6bb7a545b204b4df24b80ffff0ed04e78 + checksum: 10/ec820971659c6f15afd0e2b08bbe1596cc49711063c568cda14c96ba832f52906d9303b9d860098eb8657cef979fa3e03a1665510cca5ef051ff8f04ae2e315a languageName: node linkType: hard -"@visx/curve@npm:3.3.0": - version: 3.3.0 - resolution: "@visx/curve@npm:3.3.0" +"@visx/curve@npm:3.12.0": + version: 3.12.0 + resolution: "@visx/curve@npm:3.12.0" dependencies: "@types/d3-shape": "npm:^1.3.1" d3-shape: "npm:^1.0.6" - checksum: 10/36db95911c567408de78851eee6d98b60065c9d07b5abc7652cef072eaf5040444d8d84e73255f91bca0cfb50837f3b47db7209183211c20bc4bc1adcf13794d + checksum: 10/aa066ce88751d49919254b7a43b62803e315e7a203dfe186ad7e546a47112e0299312550ea54586d3f1e05ed2a53253a5fbf1530d06d8fead3a1cb270406c1f3 languageName: node linkType: hard -"@visx/event@npm:3.3.0": - version: 3.3.0 - resolution: "@visx/event@npm:3.3.0" +"@visx/event@npm:3.12.0": + version: 3.12.0 + resolution: "@visx/event@npm:3.12.0" dependencies: "@types/react": "npm:*" - "@visx/point": "npm:3.3.0" - checksum: 10/3cdb86848d9ae7cc8b6a14a0cc4efd7fc2186fca582ee374bb1f2dfe104ec5cd04dbdbbfc41ccb8c94e1885e2b6e6060a488f3a0a0554ad9e831ecc1b28339de + "@visx/point": "npm:3.12.0" + checksum: 10/a184a85895d2e87855d10fa273bfbb915de74d7ed51d74a067e92512a314974dcdb59df2349d74b6aef1e5f800f13e7b31c356062d142055311b73151f0532a2 languageName: node linkType: hard -"@visx/gradient@npm:3.3.0": - version: 3.3.0 - resolution: "@visx/gradient@npm:3.3.0" +"@visx/gradient@npm:3.12.0": + version: 3.12.0 + resolution: "@visx/gradient@npm:3.12.0" dependencies: "@types/react": "npm:*" prop-types: "npm:^15.5.7" peerDependencies: react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 - checksum: 10/7a371c788a09c8fc53484cfe375444332c5893f3b6fe7a86ed1bb02761deaa92e53208dab140a5a7c8d610af9396a7bcf6794d9b70d6945048fc9aeb0f4757ce + checksum: 10/568a7438294dfd8551e3ec43f7bdda911e05ab87cc7c481b64a20ba02d6c4e2c297433699ba95623a090beedf73bab785290ff3412a6363869ca93ba995f0d56 languageName: node linkType: hard -"@visx/group@npm:3.3.0": - version: 3.3.0 - resolution: "@visx/group@npm:3.3.0" +"@visx/group@npm:3.12.0": + version: 3.12.0 + resolution: "@visx/group@npm:3.12.0" dependencies: "@types/react": "npm:*" classnames: "npm:^2.3.1" prop-types: "npm:^15.6.2" peerDependencies: react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 - checksum: 10/a2f950664f43f1f1700a329f3a66bc3921ce8be35a93095a9ea28334ae4f9943c1082f21ba0f57f2bda913d2a40da8b7124235c2ae666c0e5d58a900c7148a8b + checksum: 10/bf96859d6a38d7038eb757bd39f47e4f5861f15a1dd33dd6ba6b0e823071b77214721dfa5e6e0d3a0085a3316e48b7d703f82847f7afec54b50aa8188f6149ff languageName: node linkType: hard -"@visx/point@npm:3.3.0": - version: 3.3.0 - resolution: "@visx/point@npm:3.3.0" - checksum: 10/f00f45b2bdff7c991d206320ad834bb23af90ab8c4727b9c4b9e8417b5c1b3830147bea53ceda1e1ce074571eb5e5eace1ca6bd530b61de3b4b13902668bc88c +"@visx/point@npm:3.12.0": + version: 3.12.0 + resolution: "@visx/point@npm:3.12.0" + checksum: 10/f7b7140428298df351e358413baa0779810871f5c5a7f80a6dc93071aeeb9fd85b4e7022aac75d160fec6e985dcb4f21077e9670a177237736455d9ea85b4fea languageName: node linkType: hard -"@visx/scale@npm:3.5.0": - version: 3.5.0 - resolution: "@visx/scale@npm:3.5.0" +"@visx/scale@npm:3.12.0": + version: 3.12.0 + resolution: "@visx/scale@npm:3.12.0" dependencies: - "@visx/vendor": "npm:3.5.0" - checksum: 10/75116fa455f3c5e811094da0072e0ef08105de78a183c8cf7f6ba4fda08ce0b9f85318e089c97ece3e5bb74d6933c423d0ce9570afe33231cab2df01b4c2c7f8 + "@visx/vendor": "npm:3.12.0" + checksum: 10/6d0741a59af520de14960c5111e145a7f823670911259f19b3f9712cf1791ee9b1abd15a045172032928aae8ba76dc50b01e59b634255de74b56585449b47196 languageName: node linkType: hard -"@visx/shape@npm:3.5.0": - version: 3.5.0 - resolution: "@visx/shape@npm:3.5.0" +"@visx/shape@npm:3.12.0": + version: 3.12.0 + resolution: "@visx/shape@npm:3.12.0" dependencies: "@types/d3-path": "npm:^1.0.8" "@types/d3-shape": "npm:^1.3.1" "@types/lodash": "npm:^4.14.172" "@types/react": "npm:*" - "@visx/curve": "npm:3.3.0" - "@visx/group": "npm:3.3.0" - "@visx/scale": "npm:3.5.0" + "@visx/curve": "npm:3.12.0" + "@visx/group": "npm:3.12.0" + "@visx/scale": "npm:3.12.0" classnames: "npm:^2.3.1" d3-path: "npm:^1.0.5" d3-shape: "npm:^1.2.0" @@ -10414,29 +10414,29 @@ __metadata: prop-types: "npm:^15.5.10" peerDependencies: react: ^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0 - checksum: 10/f355833125604889285d3cc0c742ef073b56b16196aee436264191b96f849c3ae0ca8665b2185c0762dacfe35a0f27182c9f159d8b1dcddd57feb0ad24d52cdf + checksum: 10/68a7e9ba651048dec721429c38f5d81a49e5b5a50460875b23ce0e7a41f62b6900920f544874756abd27f9341b2d452f448ff91710d32be439f0700ca1b95519 languageName: node linkType: hard -"@visx/tooltip@npm:3.3.0": - version: 3.3.0 - resolution: "@visx/tooltip@npm:3.3.0" +"@visx/tooltip@npm:3.12.0": + version: 3.12.0 + resolution: "@visx/tooltip@npm:3.12.0" dependencies: "@types/react": "npm:*" - "@visx/bounds": "npm:3.3.0" + "@visx/bounds": "npm:3.12.0" classnames: "npm:^2.3.1" prop-types: "npm:^15.5.10" react-use-measure: "npm:^2.0.4" peerDependencies: react: ^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0 react-dom: ^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0 - checksum: 10/d45ee4bf450f5109133fbbeb6c1daf6e7bdccba1dd053f77ee92008fb934c7c367ded28e313d02aec2dc34aea289ff9b7e4c1ac686906188602489dd317a570a + checksum: 10/f260cc14ac3de42748005370c712117b331e1be5bec0fb61306e757fedcd9af81df7314a7f7fc949f0e4db93c08a73318753782b7b64c6f0894fa389b324dbb3 languageName: node linkType: hard -"@visx/vendor@npm:3.5.0": - version: 3.5.0 - resolution: "@visx/vendor@npm:3.5.0" +"@visx/vendor@npm:3.12.0": + version: 3.12.0 + resolution: "@visx/vendor@npm:3.12.0" dependencies: "@types/d3-array": "npm:3.0.3" "@types/d3-color": "npm:3.1.0" @@ -10457,7 +10457,7 @@ __metadata: d3-time: "npm:3.1.0" d3-time-format: "npm:4.1.0" internmap: "npm:2.0.3" - checksum: 10/d893aac3d025f522b5e8035e3cc230006bbfbb188af65db8f5b86b8206521acf05e6307d4802562f2f7ace0642a13bf6a8bd518f0fa277ecbbcccb2910fefec4 + checksum: 10/34375e0bbd0b18232acd9241875a657c95d9c8cd97e52cd5a17d3c8364a88d43495ab730ecd6fea2e9641d364b49f68a030599133a1130fad887917bf5cdd379 languageName: node linkType: hard @@ -17655,11 +17655,11 @@ __metadata: "@types/yargs": "npm:17.0.33" "@typescript-eslint/eslint-plugin": "npm:8.18.1" "@typescript-eslint/parser": "npm:8.18.1" - "@visx/event": "npm:3.3.0" - "@visx/gradient": "npm:3.3.0" - "@visx/group": "npm:3.3.0" - "@visx/shape": "npm:3.5.0" - "@visx/tooltip": "npm:3.3.0" + "@visx/event": "npm:3.12.0" + "@visx/gradient": "npm:3.12.0" + "@visx/group": "npm:3.12.0" + "@visx/shape": "npm:3.12.0" + "@visx/tooltip": "npm:3.12.0" "@welldone-software/why-did-you-render": "npm:8.0.3" angular: "npm:1.8.3" angular-bindonce: "npm:0.3.1" From 7c583ed8fb11838ff8324f46b1356617f32745b8 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 17 Dec 2024 16:31:28 +0100 Subject: [PATCH 10/26] Fix: Add support for datasource variable queries (#98098) * add ds variable support * bump scenes * remove unused fixture --- .../grafana-test-datasource/datasource.ts | 25 ++++++------- .../tests/variables.spec.ts | 13 +++++++ .../grafana-test-datasource/variables.ts | 13 +++++++ package.json | 4 +-- yarn.lock | 35 +++++++++++++------ 5 files changed, 65 insertions(+), 25 deletions(-) create mode 100644 e2e/test-plugins/grafana-test-datasource/tests/variables.spec.ts create mode 100644 e2e/test-plugins/grafana-test-datasource/variables.ts diff --git a/e2e/test-plugins/grafana-test-datasource/datasource.ts b/e2e/test-plugins/grafana-test-datasource/datasource.ts index d4e61516512..4ed426f4036 100644 --- a/e2e/test-plugins/grafana-test-datasource/datasource.ts +++ b/e2e/test-plugins/grafana-test-datasource/datasource.ts @@ -11,6 +11,7 @@ import { import { MyQuery, MyDataSourceOptions, DEFAULT_QUERY, DataSourceResponse } from './types'; import { lastValueFrom } from 'rxjs'; +import { VariableSupport } from './variables'; export class DataSource extends DataSourceApi { baseUrl: string; @@ -18,6 +19,7 @@ export class DataSource extends DataSourceApi { constructor(instanceSettings: DataSourceInstanceSettings) { super(instanceSettings); this.baseUrl = instanceSettings.url!; + this.variables = new VariableSupport(); } getDefaultQuery(_: CoreApp): Partial { @@ -34,18 +36,17 @@ export class DataSource extends DataSourceApi { const from = range!.from.valueOf(); const to = range!.to.valueOf(); - // Return a constant for each query. - const data = options.targets.map((target) => { - return createDataFrame({ - refId: target.refId, - fields: [ - { name: 'Time', values: [from, to], type: FieldType.time }, - { name: 'Value', values: [target.constant, target.constant], type: FieldType.number }, - ], - }); - }); - - return { data }; + return { + data: [ + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', values: [from, to], type: FieldType.time }, + { name: 'Value', values: ['A', 'B'], type: FieldType.string }, + ], + }), + ], + }; } async request(url: string, params?: string) { diff --git a/e2e/test-plugins/grafana-test-datasource/tests/variables.spec.ts b/e2e/test-plugins/grafana-test-datasource/tests/variables.spec.ts new file mode 100644 index 00000000000..72565e5a885 --- /dev/null +++ b/e2e/test-plugins/grafana-test-datasource/tests/variables.spec.ts @@ -0,0 +1,13 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +test('should render variable editor', async ({ variableEditPage, page }) => { + await variableEditPage.datasource.set('gdev-e2etestdatasource'); + await expect(page.getByRole('textbox', { name: 'Query Text' })).toBeVisible(); +}); + +test('create new, successful variable query', async ({ variableEditPage, page }) => { + await variableEditPage.datasource.set('gdev-e2etestdatasource'); + await page.getByRole('textbox', { name: 'Query Text' }).fill('variableQuery'); + await variableEditPage.runQuery(); + await expect(variableEditPage).toDisplayPreviews(['A', 'B']); +}); diff --git a/e2e/test-plugins/grafana-test-datasource/variables.ts b/e2e/test-plugins/grafana-test-datasource/variables.ts new file mode 100644 index 00000000000..0abc380ef6a --- /dev/null +++ b/e2e/test-plugins/grafana-test-datasource/variables.ts @@ -0,0 +1,13 @@ +import { VariableSupportBase, VariableSupportType } from '@grafana/data'; +import { DEFAULT_QUERY, MyQuery } from './types'; +import { DataSource } from './datasource'; + +export class VariableSupport extends VariableSupportBase { + getType(): VariableSupportType { + return VariableSupportType.Datasource; + } + + getDefaultQuery(): Partial { + return DEFAULT_QUERY; + } +} diff --git a/package.json b/package.json index 7ec4265dabd..ca4b8ab4d4d 100644 --- a/package.json +++ b/package.json @@ -275,8 +275,8 @@ "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", "@grafana/saga-icons": "workspace:*", - "@grafana/scenes": "^5.32.0", - "@grafana/scenes-react": "^5.32.0", + "@grafana/scenes": "^5.34.0", + "@grafana/scenes-react": "^5.34.0", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/yarn.lock b/yarn.lock index 19d6396800c..4e2d68a1650 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3829,7 +3829,7 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes-react@npm:^5.32.0": +"@grafana/scenes-react@npm:^5.34.0": version: 5.34.0 resolution: "@grafana/scenes-react@npm:5.34.0" dependencies: @@ -3848,7 +3848,7 @@ __metadata: languageName: node linkType: hard -"@grafana/scenes@npm:5.34.0, @grafana/scenes@npm:^5.32.0": +"@grafana/scenes@npm:5.34.0, @grafana/scenes@npm:^5.34.0": version: 5.34.0 resolution: "@grafana/scenes@npm:5.34.0" dependencies: @@ -17552,8 +17552,8 @@ __metadata: "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" "@grafana/saga-icons": "workspace:*" - "@grafana/scenes": "npm:^5.32.0" - "@grafana/scenes-react": "npm:^5.32.0" + "@grafana/scenes": "npm:^5.34.0" + "@grafana/scenes-react": "npm:^5.34.0" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/tsconfig": "npm:^2.0.0" @@ -20699,8 +20699,8 @@ __metadata: linkType: hard "knip@npm:^5.10.0": - version: 5.41.0 - resolution: "knip@npm:5.41.0" + version: 5.40.0 + resolution: "knip@npm:5.40.0" dependencies: "@nodelib/fs.walk": "npm:1.2.8" "@snyk/github-codeowners": "npm:1.1.0" @@ -20724,7 +20724,7 @@ __metadata: bin: knip: bin/knip.js knip-bun: bin/knip-bun.js - checksum: 10/a30b986ca4331b735f188c9217cb551b2698ddbed54bc98f1bf3c1fe1e85b3aff589244dafe60d6b5c7a8ee5133726d69366965cc3ae6299b43c33787155a7a2 + checksum: 10/238d76428a1f0cd3a184ebd9aaea2e07b3b8434cdee6c6e9230d6ae31f18b93188e2b60fbd64769d2448ce5b0114e662ee536843207514b1e95060b8740218d8 languageName: node linkType: hard @@ -25155,7 +25155,7 @@ __metadata: languageName: node linkType: hard -"rc-util@npm:^5.0.1, rc-util@npm:^5.0.6, rc-util@npm:^5.15.0, rc-util@npm:^5.16.1, rc-util@npm:^5.21.0, rc-util@npm:^5.24.4, rc-util@npm:^5.26.0, rc-util@npm:^5.27.0, rc-util@npm:^5.36.0, rc-util@npm:^5.37.0, rc-util@npm:^5.38.0, rc-util@npm:^5.38.1, rc-util@npm:^5.43.0": +"rc-util@npm:^5.0.1, rc-util@npm:^5.0.6, rc-util@npm:^5.26.0": version: 5.44.2 resolution: "rc-util@npm:5.44.2" dependencies: @@ -25168,6 +25168,19 @@ __metadata: languageName: node linkType: hard +"rc-util@npm:^5.15.0, rc-util@npm:^5.16.1, rc-util@npm:^5.21.0, rc-util@npm:^5.24.4, rc-util@npm:^5.27.0, rc-util@npm:^5.36.0, rc-util@npm:^5.37.0, rc-util@npm:^5.38.0, rc-util@npm:^5.38.1, rc-util@npm:^5.43.0": + version: 5.43.0 + resolution: "rc-util@npm:5.43.0" + dependencies: + "@babel/runtime": "npm:^7.18.3" + react-is: "npm:^18.2.0" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10/6d5be9d79182c6b4c5a033ad6517b2940d3d2ac42a8e77ef5735591d182f8236f61bc7628d61e82a122d2046ec849462f3fe57c08d3a2a20279646785c34ec4a + languageName: node + linkType: hard + "rc-virtual-list@npm:^3.5.1, rc-virtual-list@npm:^3.5.2": version: 3.5.3 resolution: "rc-virtual-list@npm:3.5.3" @@ -29687,9 +29700,9 @@ __metadata: linkType: hard "type-fest@npm:^4.18.2, type-fest@npm:^4.26.1": - version: 4.30.2 - resolution: "type-fest@npm:4.30.2" - checksum: 10/c5168b159c366e4fd5b74c7f7b786bed9248c03f67e6e07d52dd5d51354447468fa7c92b9f2142c7fe9279814031f783959370242c3520de848931b65ddb48bb + version: 4.26.1 + resolution: "type-fest@npm:4.26.1" + checksum: 10/b82676194f80af228cb852e320d2ea8381c89d667d2e4d9f2bdfc8f254bccc039c7741a90c53617a4de0c9fdca8265ed18eb0888cd628f391c5c381c33a9f94b languageName: node linkType: hard From 77fafd887a6ac4bd4c4447f7ba46dee05815b706 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Dec 2024 15:32:14 +0000 Subject: [PATCH 11/26] Update dependency @rollup/plugin-node-resolve to v16 (#98114) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/grafana-data/package.json | 2 +- packages/grafana-e2e-selectors/package.json | 2 +- packages/grafana-flamegraph/package.json | 2 +- packages/grafana-icons/package.json | 2 +- packages/grafana-prometheus/package.json | 2 +- packages/grafana-runtime/package.json | 2 +- packages/grafana-schema/package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 24 ++++++++++----------- 9 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 853b9494ead..0bf08312466 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -62,7 +62,7 @@ }, "devDependencies": { "@grafana/tsconfig": "^2.0.0", - "@rollup/plugin-node-resolve": "15.3.0", + "@rollup/plugin-node-resolve": "16.0.0", "@types/dompurify": "^3.0.0", "@types/history": "4.7.11", "@types/lodash": "4.17.13", diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index 1b90bed0243..7eb8a8c004f 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -39,7 +39,7 @@ "postpack": "mv package.json.bak package.json" }, "devDependencies": { - "@rollup/plugin-node-resolve": "15.3.0", + "@rollup/plugin-node-resolve": "16.0.0", "@types/node": "22.10.2", "@types/semver": "7.5.8", "esbuild": "0.24.0", diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index 1fd96bad71b..af725ed6992 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -60,7 +60,7 @@ "@babel/preset-env": "7.26.0", "@babel/preset-react": "7.26.3", "@grafana/tsconfig": "^2.0.0", - "@rollup/plugin-node-resolve": "15.3.0", + "@rollup/plugin-node-resolve": "16.0.0", "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "^6.1.2", "@testing-library/react": "16.1.0", diff --git a/packages/grafana-icons/package.json b/packages/grafana-icons/package.json index cf6f78f7810..a00c4cac732 100644 --- a/packages/grafana-icons/package.json +++ b/packages/grafana-icons/package.json @@ -36,7 +36,7 @@ "devDependencies": { "@babel/core": "7.26.0", "@grafana/tsconfig": "^2.0.0", - "@rollup/plugin-node-resolve": "^15.3.0", + "@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-typescript": "^12.1.0", "@svgr/babel-plugin-remove-jsx-attribute": "^8.0.0", "@svgr/cli": "^8.1.0", diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index ecf4a9d725f..840b4732e63 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -79,7 +79,7 @@ "@grafana/e2e-selectors": "11.5.0-pre", "@grafana/tsconfig": "^2.0.0", "@rollup/plugin-image": "3.0.3", - "@rollup/plugin-node-resolve": "15.3.0", + "@rollup/plugin-node-resolve": "16.0.0", "@swc/core": "1.10.1", "@swc/helpers": "0.5.15", "@testing-library/dom": "10.4.0", diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index e4cf6a7e7d2..82651ce078e 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -50,7 +50,7 @@ }, "devDependencies": { "@grafana/tsconfig": "^2.0.0", - "@rollup/plugin-node-resolve": "15.3.0", + "@rollup/plugin-node-resolve": "16.0.0", "@rollup/plugin-terser": "0.4.4", "@testing-library/dom": "10.4.0", "@testing-library/react": "16.1.0", diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json index af92ae5ced7..e9fd8e9985b 100644 --- a/packages/grafana-schema/package.json +++ b/packages/grafana-schema/package.json @@ -37,7 +37,7 @@ }, "devDependencies": { "@grafana/tsconfig": "^2.0.0", - "@rollup/plugin-node-resolve": "15.3.0", + "@rollup/plugin-node-resolve": "16.0.0", "esbuild": "0.24.0", "glob": "^11.0.0", "rimraf": "6.0.1", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 77a1ab296d5..faa81ef6594 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -116,7 +116,7 @@ "@babel/core": "7.26.0", "@faker-js/faker": "^9.0.0", "@grafana/tsconfig": "^2.0.0", - "@rollup/plugin-node-resolve": "15.3.0", + "@rollup/plugin-node-resolve": "16.0.0", "@storybook/addon-a11y": "^8.4.2", "@storybook/addon-actions": "^8.4.2", "@storybook/addon-docs": "^8.4.2", diff --git a/yarn.lock b/yarn.lock index 4e2d68a1650..ca7bee08eed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3286,7 +3286,7 @@ __metadata: "@braintree/sanitize-url": "npm:7.0.1" "@grafana/schema": "npm:11.5.0-pre" "@grafana/tsconfig": "npm:^2.0.0" - "@rollup/plugin-node-resolve": "npm:15.3.0" + "@rollup/plugin-node-resolve": "npm:16.0.0" "@types/d3-interpolate": "npm:^3.0.0" "@types/dompurify": "npm:^3.0.0" "@types/history": "npm:4.7.11" @@ -3337,7 +3337,7 @@ __metadata: resolution: "@grafana/e2e-selectors@workspace:packages/grafana-e2e-selectors" dependencies: "@grafana/tsconfig": "npm:^2.0.0" - "@rollup/plugin-node-resolve": "npm:15.3.0" + "@rollup/plugin-node-resolve": "npm:16.0.0" "@types/node": "npm:22.10.2" "@types/semver": "npm:7.5.8" esbuild: "npm:0.24.0" @@ -3465,7 +3465,7 @@ __metadata: "@grafana/tsconfig": "npm:^2.0.0" "@grafana/ui": "npm:11.5.0-pre" "@leeoniya/ufuzzy": "npm:1.0.17" - "@rollup/plugin-node-resolve": "npm:15.3.0" + "@rollup/plugin-node-resolve": "npm:16.0.0" "@testing-library/dom": "npm:10.4.0" "@testing-library/jest-dom": "npm:^6.1.2" "@testing-library/react": "npm:16.1.0" @@ -3664,7 +3664,7 @@ __metadata: "@prometheus-io/lezer-promql": "npm:0.300.1" "@reduxjs/toolkit": "npm:2.5.0" "@rollup/plugin-image": "npm:3.0.3" - "@rollup/plugin-node-resolve": "npm:15.3.0" + "@rollup/plugin-node-resolve": "npm:16.0.0" "@swc/core": "npm:1.10.1" "@swc/helpers": "npm:0.5.15" "@testing-library/dom": "npm:10.4.0" @@ -3761,7 +3761,7 @@ __metadata: "@grafana/schema": "npm:11.5.0-pre" "@grafana/tsconfig": "npm:^2.0.0" "@grafana/ui": "npm:11.5.0-pre" - "@rollup/plugin-node-resolve": "npm:15.3.0" + "@rollup/plugin-node-resolve": "npm:16.0.0" "@rollup/plugin-terser": "npm:0.4.4" "@testing-library/dom": "npm:10.4.0" "@testing-library/react": "npm:16.1.0" @@ -3800,7 +3800,7 @@ __metadata: dependencies: "@babel/core": "npm:7.26.0" "@grafana/tsconfig": "npm:^2.0.0" - "@rollup/plugin-node-resolve": "npm:^15.3.0" + "@rollup/plugin-node-resolve": "npm:^16.0.0" "@rollup/plugin-typescript": "npm:^12.1.0" "@svgr/babel-plugin-remove-jsx-attribute": "npm:^8.0.0" "@svgr/cli": "npm:^8.1.0" @@ -3876,7 +3876,7 @@ __metadata: resolution: "@grafana/schema@workspace:packages/grafana-schema" dependencies: "@grafana/tsconfig": "npm:^2.0.0" - "@rollup/plugin-node-resolve": "npm:15.3.0" + "@rollup/plugin-node-resolve": "npm:16.0.0" esbuild: "npm:0.24.0" glob: "npm:^11.0.0" rimraf: "npm:6.0.1" @@ -3964,7 +3964,7 @@ __metadata: "@react-aria/focus": "npm:3.19.0" "@react-aria/overlays": "npm:3.24.0" "@react-aria/utils": "npm:3.26.0" - "@rollup/plugin-node-resolve": "npm:15.3.0" + "@rollup/plugin-node-resolve": "npm:16.0.0" "@storybook/addon-a11y": "npm:^8.4.2" "@storybook/addon-actions": "npm:^8.4.2" "@storybook/addon-docs": "npm:^8.4.2" @@ -6597,9 +6597,9 @@ __metadata: languageName: node linkType: hard -"@rollup/plugin-node-resolve@npm:15.3.0, @rollup/plugin-node-resolve@npm:^15.3.0": - version: 15.3.0 - resolution: "@rollup/plugin-node-resolve@npm:15.3.0" +"@rollup/plugin-node-resolve@npm:16.0.0, @rollup/plugin-node-resolve@npm:^16.0.0": + version: 16.0.0 + resolution: "@rollup/plugin-node-resolve@npm:16.0.0" dependencies: "@rollup/pluginutils": "npm:^5.0.1" "@types/resolve": "npm:1.20.2" @@ -6611,7 +6611,7 @@ __metadata: peerDependenciesMeta: rollup: optional: true - checksum: 10/214596dd0ecf0822a135e6cb604f6a4469bac4a9d6b43608d277b47c34762e800b79f5f1c18ea0f7317448165ac0cff2439b35446641e093a5bc5c372940c819 + checksum: 10/018a97667d68bd78d6b1de5597680dcc5785f9339a936984a5715ad2cd7c6f2c85fb9448552b94e6903db35e2d3b218b54e5e9ca048257f2d3bdea2e05d886c7 languageName: node linkType: hard From 9c6b86958c0ed970ce3d040fca25a7f690e0eb8e Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 17 Dec 2024 15:32:54 +0000 Subject: [PATCH 12/26] Chore: fix flaky unit test (#98102) fix flaky unit test --- .../search/state/SearchStateManager.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/public/app/features/search/state/SearchStateManager.test.ts b/public/app/features/search/state/SearchStateManager.test.ts index 893f33063a0..2a1f60dddd0 100644 --- a/public/app/features/search/state/SearchStateManager.test.ts +++ b/public/app/features/search/state/SearchStateManager.test.ts @@ -1,3 +1,5 @@ +import { waitFor } from '@testing-library/react'; + import { DataFrameView } from '@grafana/data'; import { locationService } from '@grafana/runtime'; @@ -33,6 +35,14 @@ describe('SearchStateManager', () => { view: new DataFrameView({ fields: [], length: 0 }), }); + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + it('Can get search state manager with initial state', async () => { const stm = getSearchStateManager(); expect(stm.state.layout).toBe(SearchLayout.Folders); @@ -115,9 +125,9 @@ describe('SearchStateManager', () => { stm.onQueryChange('debugging'); - await wait(150); + jest.advanceTimersByTime(150); - expect(stm.state.result?.totalRows).toEqual(10); + await waitFor(() => expect(stm.state.result?.totalRows).toEqual(10)); }); }); }); From 01666fc1ae57b1293f8ac32a23ed51eafa08cb6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Jamr=C3=B3z?= Date: Tue, 17 Dec 2024 16:35:11 +0100 Subject: [PATCH 13/26] Explore Profiles: Preinstall for onprem Grafana instances (#97775) --- pkg/setting/setting_plugins.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/setting/setting_plugins.go b/pkg/setting/setting_plugins.go index f85ca79d4e8..d798c1206c9 100644 --- a/pkg/setting/setting_plugins.go +++ b/pkg/setting/setting_plugins.go @@ -30,6 +30,7 @@ var ( defaultPreinstallPlugins = map[string]InstallPlugin{ // Default preinstalled plugins "grafana-lokiexplore-app": {"grafana-lokiexplore-app", "", ""}, + "grafana-pyroscope-app": {"grafana-pyroscope-app", "", ""}, } ) From 27c979b0bf682b0e8207399d1a551a828c39cf47 Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Tue, 17 Dec 2024 08:35:55 -0700 Subject: [PATCH 14/26] Dashboard SchemaV2: Use id in PanelSpec (#98069) * PanelSpec: replace uid with id * Update examples --- .../dashboard/v2alpha0/dashboard.gen.ts | 4 +-- .../dashboard/v2alpha0/dashboard.schema.cue | 2 +- .../src/schema/dashboard/v2alpha0/examples.ts | 6 ++-- ...sformSceneToSaveModelSchemaV2.test.ts.snap | 6 ++-- .../transformSaveModelSchemaV2ToScene.test.ts | 10 +++---- .../transformSaveModelSchemaV2ToScene.ts | 9 +++--- .../transformSceneToSaveModelSchemaV2.test.ts | 2 +- .../transformSceneToSaveModelSchemaV2.ts | 6 ++-- .../dashboard-scene/v2schema/test-helpers.ts | 28 +++++++++---------- 9 files changed, 35 insertions(+), 38 deletions(-) diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts index 44f81fd03bf..c92ddd9e94d 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts @@ -694,7 +694,7 @@ export const defaultGridLayoutKind = (): GridLayoutKind => ({ }); export interface PanelSpec { - uid: string; + id: number; title: string; description: string; links: DataLink[]; @@ -704,7 +704,7 @@ export interface PanelSpec { } export const defaultPanelSpec = (): PanelSpec => ({ - uid: "", + id: 0, title: "", description: "", links: [], diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue index d6e587449a3..4df22d4cc27 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue @@ -479,7 +479,7 @@ GridLayoutKind: { } PanelSpec: { - uid: string + id: number title: string description: string links: [...DataLink] diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts index 198d6fe62a5..cb4b024e5ac 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts @@ -108,7 +108,7 @@ export const handyTestingSchema: DashboardV2Spec = { }, ], elements: { - 'test-panel-uid': { + 'panel-1': { kind: 'Panel', spec: { data: { @@ -168,7 +168,7 @@ export const handyTestingSchema: DashboardV2Spec = { { title: 'Test Link 2', url: 'http://test2.com' }, ], title: 'Test Panel', - uid: 'test-panel-uid', + id: 1, vizConfig: { kind: 'timeseries', spec: { @@ -192,7 +192,7 @@ export const handyTestingSchema: DashboardV2Spec = { spec: { element: { kind: 'ElementReference', - name: 'test-panel-uid', + name: 'panel-1', }, height: 100, width: 200, diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap index 2a489700f62..86644958a4a 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap @@ -86,7 +86,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model "description": "Test Description", "editable": true, "elements": { - "test-panel-uid": { + "panel-1": { "kind": "Panel", "spec": { "data": { @@ -98,6 +98,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model }, }, "description": "Test Description", + "id": 1, "links": [ { "targetBlank": true, @@ -110,7 +111,6 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model }, ], "title": "Test Panel", - "uid": "test-panel-uid", "vizConfig": { "kind": "timeseries", "spec": { @@ -134,7 +134,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model "spec": { "element": { "kind": "ElementReference", - "name": "test-panel-uid", + "name": "panel-1", }, "height": 0, "width": 0, diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts index f8ed28b694c..12791e79736 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts @@ -213,7 +213,7 @@ describe('transformSaveModelSchemaV2ToScene', () => { // Layout const layout = scene.state.body as DefaultGridLayoutManager; expect(layout.state.grid.state.children.length).toBe(1); - expect(layout.state.grid.state.children[0].state.key).toBe(`grid-item-${Object.keys(dash.elements)[0]}`); + expect(layout.state.grid.state.children[0].state.key).toBe(`grid-item-${dash.elements['panel-1'].spec.id}`); const gridLayoutItemSpec = dash.layout.spec.items[0].spec; expect(layout.state.grid.state.children[0].state.width).toBe(gridLayoutItemSpec.width); expect(layout.state.grid.state.children[0].state.height).toBe(gridLayoutItemSpec.height); @@ -222,13 +222,13 @@ describe('transformSaveModelSchemaV2ToScene', () => { // Transformations expect((vizPanel.state.$data as SceneDataTransformer)?.state.transformations[0]).toEqual( - dash.elements['test-panel-uid'].spec.data.spec.transformations[0].spec + dash.elements['panel-1'].spec.data.spec.transformations[0].spec ); }); it('should set panel ds if it is mixed DS', () => { const dashboard = cloneDeep(defaultDashboard); - dashboard.spec.elements['test-panel-uid'].spec.data.spec.queries.push({ + dashboard.spec.elements['panel-1'].spec.data.spec.queries.push({ kind: 'PanelQuery', spec: { refId: 'A', @@ -256,7 +256,7 @@ describe('transformSaveModelSchemaV2ToScene', () => { it('should set panel ds as undefined if it is not mixed DS', () => { const dashboard = cloneDeep(defaultDashboard); - dashboard.spec.elements['test-panel-uid'].spec.data.spec.queries.push({ + dashboard.spec.elements['panel-1'].spec.data.spec.queries.push({ kind: 'PanelQuery', spec: { refId: 'A', @@ -284,7 +284,7 @@ describe('transformSaveModelSchemaV2ToScene', () => { it('should set panel ds as mixed if one ds is undefined', () => { const dashboard = cloneDeep(defaultDashboard); - dashboard.spec.elements['test-panel-uid'].spec.data.spec.queries.push({ + dashboard.spec.elements['panel-1'].spec.data.spec.queries.push({ kind: 'PanelQuery', spec: { refId: 'A', diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index 86ec1760744..ff3f2896f62 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -72,7 +72,7 @@ import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; import { setDashboardPanelContext } from '../scene/setDashboardPanelContext'; import { preserveDashboardSceneStateInLocalStorage } from '../utils/dashboardSessionState'; -import { getDashboardSceneFor, getIntervalsFromQueryString } from '../utils/utils'; +import { getDashboardSceneFor, getIntervalsFromQueryString, getVizPanelKeyForPanelId } from '../utils/utils'; import { SnapshotVariable } from './custom-variables/SnapshotVariable'; import { registerPanelInteractionsReporter } from './transformSaveModelToScene'; @@ -194,7 +194,7 @@ function createSceneGridLayoutForItems(dashboard: DashboardV2Spec): SceneGridIte const vizPanel = buildVizPanel(panel); return new DashboardGridItem({ - key: `grid-item-${panel.spec.uid}`, + key: `grid-item-${panel.spec.id}`, x: element.spec.x, y: element.spec.y, width: element.spec.width, @@ -231,7 +231,7 @@ function buildVizPanel(panel: PanelKind): VizPanel { const timeOverrideShown = (queryOptions.timeFrom || queryOptions.timeShift) && !queryOptions.hideTimeOverride; const vizPanelState: VizPanelState = { - key: panel.spec.uid, + key: getVizPanelKeyForPanelId(panel.spec.id), title: panel.spec.title, description: panel.spec.description, pluginId: panel.spec.vizConfig.kind, @@ -341,8 +341,7 @@ export function createPanelDataProvider(panelKind: PanelKind): SceneDataProvider queryCachingTTL: panel.data.spec.queryOptions.queryCachingTTL, minInterval: panel.data.spec.queryOptions.interval ?? undefined, dataLayerFilter: { - // FIXME: This is asking for a number as panel ID but here the uid of a panel is string - panelId: Number.isNaN(parseInt(panel.uid, 10)) ? 0 : parseInt(panel.uid, 10), + panelId: panel.id, }, $behaviors: [new DashboardDatasourceBehaviour({})], }); diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts index fd6169589f4..7963f6883de 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts @@ -140,7 +140,7 @@ describe('transformSceneToSaveModelSchemaV2', () => { children: [ new DashboardGridItem({ body: new VizPanel({ - key: 'test-panel-uid', + key: 'panel-1', pluginId: 'timeseries', title: 'Test Panel', titleItems: [ diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index 92780e9fcde..562b354255c 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -45,7 +45,7 @@ import { PanelTimeRange } from '../scene/PanelTimeRange'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; -import { getQueryRunnerFor } from '../utils/utils'; +import { getPanelIdForVizPanel, getQueryRunnerFor, getVizPanelKeyForPanelId } from '../utils/utils'; import { sceneVariablesSetToSchemaV2Variables } from './sceneVariablesSetToVariables'; import { transformCursorSynctoEnum } from './transformToV2TypesUtils'; @@ -214,7 +214,7 @@ function getElements(state: DashboardSceneState) { const elementSpec: PanelKind = { kind: 'Panel', spec: { - uid: vizPanel.state.key ?? '', // FIXME: why is key optional? + id: getPanelIdForVizPanel(vizPanel), title: vizPanel.state.title, description: vizPanel.state.description ?? '', links: getPanelLinks(vizPanel), @@ -354,7 +354,7 @@ function getVizPanelQueryOptions(vizPanel: VizPanel): QueryOptionsSpec { function createElements(panels: PanelKind[]): Record { return panels.reduce( (acc, panel) => { - const key = panel.spec.uid; + const key = getVizPanelKeyForPanelId(panel.spec.id); acc[key] = panel; return acc; }, diff --git a/public/app/features/dashboard-scene/v2schema/test-helpers.ts b/public/app/features/dashboard-scene/v2schema/test-helpers.ts index dc18f36e38d..70f3e821528 100644 --- a/public/app/features/dashboard-scene/v2schema/test-helpers.ts +++ b/public/app/features/dashboard-scene/v2schema/test-helpers.ts @@ -15,7 +15,7 @@ import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alp import { DashboardScene } from '../scene/DashboardScene'; import { VizPanelLinks } from '../scene/PanelLinks'; import { TypedVariableModelV2 } from '../serialization/transformSaveModelSchemaV2ToScene'; -import { getQueryRunnerFor } from '../utils/utils'; +import { getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils'; type SceneVariableConstructor> = new ( initialState: Partial @@ -60,19 +60,19 @@ export function validateVariable< } export function validateVizPanel(vizPanel: VizPanel, dash: DashboardV2Spec) { - expect(vizPanel.state.title).toBe(dash.elements['test-panel-uid'].spec.title); - expect(vizPanel.state.description).toBe(dash.elements['test-panel-uid'].spec.description); - expect(vizPanel.state.pluginId).toBe(dash.elements['test-panel-uid'].spec.vizConfig.kind); - expect(vizPanel.state.pluginVersion).toBe(dash.elements['test-panel-uid'].spec.vizConfig.spec.pluginVersion); - expect(vizPanel.state.options).toEqual(dash.elements['test-panel-uid'].spec.vizConfig.spec.options); - expect(vizPanel.state.fieldConfig).toEqual(dash.elements['test-panel-uid'].spec.vizConfig.spec.fieldConfig); - expect(vizPanel.state.key).toBe(dash.elements['test-panel-uid'].spec.uid); - expect(vizPanel.state.displayMode).toBe(dash.elements['test-panel-uid'].spec.transparent ? 'transparent' : 'default'); + expect(vizPanel.state.title).toBe(dash.elements['panel-1'].spec.title); + expect(vizPanel.state.description).toBe(dash.elements['panel-1'].spec.description); + expect(vizPanel.state.pluginId).toBe(dash.elements['panel-1'].spec.vizConfig.kind); + expect(vizPanel.state.pluginVersion).toBe(dash.elements['panel-1'].spec.vizConfig.spec.pluginVersion); + expect(vizPanel.state.options).toEqual(dash.elements['panel-1'].spec.vizConfig.spec.options); + expect(vizPanel.state.fieldConfig).toEqual(dash.elements['panel-1'].spec.vizConfig.spec.fieldConfig); + expect(getPanelIdForVizPanel(vizPanel)).toBe(dash.elements['panel-1'].spec.id); + expect(vizPanel.state.displayMode).toBe(dash.elements['panel-1'].spec.transparent ? 'transparent' : 'default'); expect(vizPanel.state.$data).toBeInstanceOf(SceneDataTransformer); const dataTransformer = vizPanel.state.$data as SceneDataTransformer; expect(dataTransformer.state.transformations[0]).toEqual( - dash.elements['test-panel-uid'].spec.data.spec.transformations[0].spec + dash.elements['panel-1'].spec.data.spec.transformations[0].spec ); expect(dataTransformer.state.$data).toBeInstanceOf(SceneQueryRunner); @@ -87,9 +87,7 @@ export function validateVizPanel(vizPanel: VizPanel, dash: DashboardV2Spec) { expect(queryRunner.state.minInterval).toBe('1m'); const titleItems = vizPanel.state.titleItems as SceneObject[]; const vizPanelLinks = titleItems[0] as VizPanelLinks; - expect(vizPanelLinks.state.rawLinks).toHaveLength(dash.elements['test-panel-uid'].spec.links.length); - expect(vizPanelLinks.state.rawLinks).toEqual(dash.elements['test-panel-uid'].spec.links); - // FIXME: This is asking for a number as panel ID but here the uid of a panel is string - // will be fixed once scenes package is updated to support string panel ID - // expect(queryRunner.state.dataLayerFilter?.panelId).toBe(0); + expect(vizPanelLinks.state.rawLinks).toHaveLength(dash.elements['panel-1'].spec.links.length); + expect(vizPanelLinks.state.rawLinks).toEqual(dash.elements['panel-1'].spec.links); + expect(queryRunner.state.dataLayerFilter?.panelId).toBe(dash.elements['panel-1'].spec.id); } From 57d94ed01f92a89f32ed6ab8d44f1b827215ae49 Mon Sep 17 00:00:00 2001 From: Adam Simpson Date: Tue, 17 Dec 2024 10:38:44 -0500 Subject: [PATCH 15/26] ds-querier: return error if failed to get ds client (#97698) * ds-querier: return error if failed to get ds client * add log line * no empty ref * fix test --- pkg/registry/apis/query/query.go | 11 +++++++++-- pkg/tests/apis/query/query_test.go | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/registry/apis/query/query.go b/pkg/registry/apis/query/query.go index be8b24886c8..bdd1a8f227e 100644 --- a/pkg/registry/apis/query/query.go +++ b/pkg/registry/apis/query/query.go @@ -125,7 +125,7 @@ func (r *queryREST) Connect(connectCtx context.Context, name string, _ runtime.O var refError ErrorWithRefID statusCode := http.StatusBadRequest message := err - refID := "" + refID := "A" if errors.Is(err, datasources.ErrDataSourceNotFound) { statusCode = http.StatusNotFound @@ -187,6 +187,11 @@ func (b *QueryAPIBuilder) execute(ctx context.Context, req parsedRequestInfo) (q qdr, err = b.executeConcurrentQueries(ctx, req.Requests) } + if err != nil { + b.log.Debug("error in query phase, skipping expressions", "error", err) + return qdr, err //return early here to prevent expressions from being executed if we got an error during the query phase + } + if len(req.Expressions) > 0 { b.log.Debug("executing expressions") qdr, err = b.handleExpressions(ctx, req, qdr) @@ -232,7 +237,9 @@ func (b *QueryAPIBuilder) handleQuerySingleDatasource(ctx context.Context, req d req.Headers, ) if err != nil { - return nil, err + b.log.Debug("error getting single datasource client", "error", err, "reqUid", req.UID) + qdr := buildErrorResponse(err, req) + return qdr, err } code, rsp, err := client.QueryData(ctx, *req.Request) diff --git a/pkg/tests/apis/query/query_test.go b/pkg/tests/apis/query/query_test.go index 66ca6696637..40143e3804b 100644 --- a/pkg/tests/apis/query/query_test.go +++ b/pkg/tests/apis/query/query_test.go @@ -140,7 +140,7 @@ func TestIntegrationSimpleQuery(t *testing.T) { require.Error(t, err, "expecting a 400") require.JSONEq(t, `{ "results": { - "": { + "A": { "error": "[sse.dependencyError] did not execute expression [Y] due to a failure to of the dependent expression or query [X]", "status": 400, "errorSource": "" From d6ebec692138d5dce351f1a26e55c293c6fb6d4a Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 17 Dec 2024 17:02:37 +0100 Subject: [PATCH 16/26] E2E: Fix selectors related to scenes powered dashboards (#98112) * fix broken scenes selectors * fix broken test --- packages/grafana-e2e-selectors/src/resolver.test.ts | 2 +- packages/grafana-e2e-selectors/src/selectors/pages.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/grafana-e2e-selectors/src/resolver.test.ts b/packages/grafana-e2e-selectors/src/resolver.test.ts index fe294800416..a306f9261ed 100644 --- a/packages/grafana-e2e-selectors/src/resolver.test.ts +++ b/packages/grafana-e2e-selectors/src/resolver.test.ts @@ -14,7 +14,7 @@ describe('Resolver', () => { ); expect(pages.Alerting.AddAlertRule.url).toBe('/alerting/new/alerting'); expect(pages.AddDashboard.Settings.Variables.Edit.url('test')).toBe( - '/dashboard/new?orgId=1&editview=templating&editIndex=test' + '/dashboard/new?orgId=1&editview=variables&editIndex=test' ); }); diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index becd0b28646..29c310d6aff 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -155,8 +155,9 @@ export const versionedPages = { }, Edit: { url: { - [MIN_GRAFANA_VERSION]: (annotationIndex: string) => - `/dashboard/new?orgId=1&editview=templating&editIndex=${annotationIndex}`, + '11.3.0': (editIndex: string) => `/dashboard/new?orgId=1&editview=variables&editIndex=${editIndex}`, + [MIN_GRAFANA_VERSION]: (editIndex: string) => + `/dashboard/new?orgId=1&editview=templating&editIndex=${editIndex}`, }, }, }, @@ -344,6 +345,7 @@ export const versionedPages = { Variables: { List: { url: { + '11.3.0': (dashboardUid: string) => `/d/${dashboardUid}?editview=variables`, [MIN_GRAFANA_VERSION]: (dashboardUid: string) => `/d/${dashboardUid}?editview=templating`, }, addVariableCTAV2: { @@ -376,6 +378,7 @@ export const versionedPages = { }, Edit: { urlParams: { + '11.3.0': (editIndex: string) => `editview=variables&editIndex=${editIndex}`, [MIN_GRAFANA_VERSION]: (editIndex: string) => `editview=templating&editIndex=${editIndex}`, }, General: { From 0cd4c751a680869f4aac8489a9334ea1f74d4dfa Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Tue, 17 Dec 2024 17:05:38 +0100 Subject: [PATCH 17/26] Scopes search: Don't regex-escape filter input value (#98117) --- public/app/features/scopes/internal/ScopesTreeSearch.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/public/app/features/scopes/internal/ScopesTreeSearch.tsx b/public/app/features/scopes/internal/ScopesTreeSearch.tsx index 180a0d23229..273d2fa24b0 100644 --- a/public/app/features/scopes/internal/ScopesTreeSearch.tsx +++ b/public/app/features/scopes/internal/ScopesTreeSearch.tsx @@ -46,6 +46,7 @@ export function ScopesTreeSearch({ anyChildExpanded, nodePath, query, onNodeUpda value={inputState.value} className={styles.input} data-testid="scopes-tree-search" + escapeRegex={false} onChange={(value) => { setInputState({ value, isDirty: true }); }} From 146860930bdcb5b36a1de6632fb1812b5c614446 Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Tue, 17 Dec 2024 17:20:23 +0100 Subject: [PATCH 18/26] Build: update vektah/gqlparser/v2 to v2.5.20 to address Dependabot alert (#98106) --- go.work.sum | 2 ++ pkg/build/go.mod | 2 +- pkg/build/go.sum | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/go.work.sum b/go.work.sum index 8ad6fe5e947..e9eb2945307 100644 --- a/go.work.sum +++ b/go.work.sum @@ -342,6 +342,8 @@ github.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrd github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 h1:rFw4nCn9iMW+Vajsk51NtYIcwSTkXr+JGrMd36kTDJw= github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +github.com/agnivade/levenshtein v1.2.0 h1:U9L4IOT0Y3i0TIlUIDJ7rVUziKi/zPbrJGaFrtYH3SY= +github.com/agnivade/levenshtein v1.2.0/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9 h1:7kQgkwGRoLzC9K0oyXdJo7nve/bynv/KwUsxbiTlzAM= github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19 h1:iXUgAaqDcIUGbRoy2TdeofRG/j1zpGRSEmNK05T+bi8= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= diff --git a/pkg/build/go.mod b/pkg/build/go.mod index 03cbd640b2a..48785039e51 100644 --- a/pkg/build/go.mod +++ b/pkg/build/go.mod @@ -106,7 +106,7 @@ require ( github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/term v0.5.0 // indirect github.com/sosodev/duration v1.2.0 // indirect - github.com/vektah/gqlparser/v2 v2.5.11 // indirect + github.com/vektah/gqlparser/v2 v2.5.20 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.0.0-20240518090000-14441aefdf88 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.4.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 // indirect diff --git a/pkg/build/go.sum b/pkg/build/go.sum index a3c02c024d4..6696813147d 100644 --- a/pkg/build/go.sum +++ b/pkg/build/go.sum @@ -221,8 +221,8 @@ github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ= github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= -github.com/vektah/gqlparser/v2 v2.5.11 h1:JJxLtXIoN7+3x6MBdtIP59TP1RANnY7pXOaDnADQSf8= -github.com/vektah/gqlparser/v2 v2.5.11/go.mod h1:1rCcfwB2ekJofmluGWXMSEnPMZgbxzwj6FaZ/4OT8Cc= +github.com/vektah/gqlparser/v2 v2.5.20 h1:kPaWbhBntxoZPaNdBaIPT1Kh0i1b/onb5kXgEdP5JCo= +github.com/vektah/gqlparser/v2 v2.5.20/go.mod h1:xMl+ta8a5M1Yo1A1Iwt/k7gSpscwSnHZdw7tfhEGfTM= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= From d8ddbcda5160806bc56b6ec63a182ac96755cad1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Dec 2024 16:22:17 +0000 Subject: [PATCH 19/26] Update dependency @grafana/experimental to v2.1.6 (#98121) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- .../grafana-o11y-ds-frontend/package.json | 2 +- packages/grafana-prometheus/package.json | 2 +- packages/grafana-sql/package.json | 2 +- .../datasource/azuremonitor/package.json | 2 +- .../datasource/cloud-monitoring/package.json | 2 +- .../package.json | 2 +- .../grafana-testdata-datasource/package.json | 2 +- .../plugins/datasource/jaeger/package.json | 2 +- .../app/plugins/datasource/mssql/package.json | 2 +- .../app/plugins/datasource/mysql/package.json | 2 +- .../app/plugins/datasource/tempo/package.json | 2 +- .../plugins/datasource/zipkin/package.json | 2 +- yarn.lock | 49 +++++++------------ 14 files changed, 31 insertions(+), 44 deletions(-) diff --git a/package.json b/package.json index ca4b8ab4d4d..763bb4b5587 100644 --- a/package.json +++ b/package.json @@ -263,7 +263,7 @@ "@grafana/azure-sdk": "0.0.5", "@grafana/data": "workspace:*", "@grafana/e2e-selectors": "workspace:*", - "@grafana/experimental": "2.1.5", + "@grafana/experimental": "2.1.6", "@grafana/faro-core": "^1.3.6", "@grafana/faro-web-sdk": "^1.3.6", "@grafana/faro-web-tracing": "^1.8.2", diff --git a/packages/grafana-o11y-ds-frontend/package.json b/packages/grafana-o11y-ds-frontend/package.json index 4fffc7c8726..8c3812b9b5b 100644 --- a/packages/grafana-o11y-ds-frontend/package.json +++ b/packages/grafana-o11y-ds-frontend/package.json @@ -20,7 +20,7 @@ "@emotion/css": "11.13.5", "@grafana/data": "11.5.0-pre", "@grafana/e2e-selectors": "11.5.0-pre", - "@grafana/experimental": "2.1.5", + "@grafana/experimental": "2.1.6", "@grafana/runtime": "11.5.0-pre", "@grafana/schema": "11.5.0-pre", "@grafana/ui": "11.5.0-pre", diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 840b4732e63..2c5efcd63a8 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -39,7 +39,7 @@ "@emotion/css": "11.13.5", "@floating-ui/react": "0.27.2", "@grafana/data": "11.5.0-pre", - "@grafana/experimental": "2.1.5", + "@grafana/experimental": "2.1.6", "@grafana/faro-web-sdk": "1.12.2", "@grafana/runtime": "11.5.0-pre", "@grafana/schema": "11.5.0-pre", diff --git a/packages/grafana-sql/package.json b/packages/grafana-sql/package.json index 4677661d582..add42c7db3a 100644 --- a/packages/grafana-sql/package.json +++ b/packages/grafana-sql/package.json @@ -17,7 +17,7 @@ "@emotion/css": "11.13.5", "@grafana/data": "11.5.0-pre", "@grafana/e2e-selectors": "11.5.0-pre", - "@grafana/experimental": "2.1.5", + "@grafana/experimental": "2.1.6", "@grafana/runtime": "11.5.0-pre", "@grafana/ui": "11.5.0-pre", "@react-awesome-query-builder/ui": "6.6.4", diff --git a/public/app/plugins/datasource/azuremonitor/package.json b/public/app/plugins/datasource/azuremonitor/package.json index 89024d28245..ecf8a6538c1 100644 --- a/public/app/plugins/datasource/azuremonitor/package.json +++ b/public/app/plugins/datasource/azuremonitor/package.json @@ -6,7 +6,7 @@ "dependencies": { "@emotion/css": "11.13.5", "@grafana/data": "11.5.0-pre", - "@grafana/experimental": "2.1.5", + "@grafana/experimental": "2.1.6", "@grafana/runtime": "11.5.0-pre", "@grafana/schema": "11.5.0-pre", "@grafana/ui": "11.5.0-pre", diff --git a/public/app/plugins/datasource/cloud-monitoring/package.json b/public/app/plugins/datasource/cloud-monitoring/package.json index 805436c2134..21aa0c1ae4f 100644 --- a/public/app/plugins/datasource/cloud-monitoring/package.json +++ b/public/app/plugins/datasource/cloud-monitoring/package.json @@ -6,7 +6,7 @@ "dependencies": { "@emotion/css": "11.13.5", "@grafana/data": "11.5.0-pre", - "@grafana/experimental": "2.1.5", + "@grafana/experimental": "2.1.6", "@grafana/google-sdk": "0.1.2", "@grafana/runtime": "11.5.0-pre", "@grafana/schema": "11.5.0-pre", diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/package.json b/public/app/plugins/datasource/grafana-postgresql-datasource/package.json index c132726eada..e94ed543d3a 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/package.json +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/package.json @@ -6,7 +6,7 @@ "dependencies": { "@emotion/css": "11.13.5", "@grafana/data": "11.5.0-pre", - "@grafana/experimental": "2.1.5", + "@grafana/experimental": "2.1.6", "@grafana/runtime": "11.5.0-pre", "@grafana/sql": "11.5.0-pre", "@grafana/ui": "11.5.0-pre", diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/package.json b/public/app/plugins/datasource/grafana-testdata-datasource/package.json index a5054838cc0..022c8b910c8 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/package.json +++ b/public/app/plugins/datasource/grafana-testdata-datasource/package.json @@ -6,7 +6,7 @@ "dependencies": { "@emotion/css": "11.13.5", "@grafana/data": "11.5.0-pre", - "@grafana/experimental": "2.1.5", + "@grafana/experimental": "2.1.6", "@grafana/runtime": "11.5.0-pre", "@grafana/schema": "11.5.0-pre", "@grafana/ui": "11.5.0-pre", diff --git a/public/app/plugins/datasource/jaeger/package.json b/public/app/plugins/datasource/jaeger/package.json index f8fcb665661..224537f9dc3 100644 --- a/public/app/plugins/datasource/jaeger/package.json +++ b/public/app/plugins/datasource/jaeger/package.json @@ -7,7 +7,7 @@ "@emotion/css": "11.13.5", "@grafana/data": "workspace:*", "@grafana/e2e-selectors": "workspace:*", - "@grafana/experimental": "2.1.5", + "@grafana/experimental": "2.1.6", "@grafana/o11y-ds-frontend": "workspace:*", "@grafana/runtime": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/public/app/plugins/datasource/mssql/package.json b/public/app/plugins/datasource/mssql/package.json index c5a579cde88..53b5d4a6f95 100644 --- a/public/app/plugins/datasource/mssql/package.json +++ b/public/app/plugins/datasource/mssql/package.json @@ -6,7 +6,7 @@ "dependencies": { "@emotion/css": "11.13.5", "@grafana/data": "11.5.0-pre", - "@grafana/experimental": "2.1.5", + "@grafana/experimental": "2.1.6", "@grafana/runtime": "11.5.0-pre", "@grafana/sql": "11.5.0-pre", "@grafana/ui": "11.5.0-pre", diff --git a/public/app/plugins/datasource/mysql/package.json b/public/app/plugins/datasource/mysql/package.json index c45ae857e43..101349bab6b 100644 --- a/public/app/plugins/datasource/mysql/package.json +++ b/public/app/plugins/datasource/mysql/package.json @@ -6,7 +6,7 @@ "dependencies": { "@emotion/css": "11.13.5", "@grafana/data": "11.5.0-pre", - "@grafana/experimental": "2.1.5", + "@grafana/experimental": "2.1.6", "@grafana/runtime": "11.5.0-pre", "@grafana/sql": "11.5.0-pre", "@grafana/ui": "11.5.0-pre", diff --git a/public/app/plugins/datasource/tempo/package.json b/public/app/plugins/datasource/tempo/package.json index d36b6293a86..bebcfebf87b 100644 --- a/public/app/plugins/datasource/tempo/package.json +++ b/public/app/plugins/datasource/tempo/package.json @@ -7,7 +7,7 @@ "@emotion/css": "11.13.5", "@grafana/data": "workspace:*", "@grafana/e2e-selectors": "workspace:*", - "@grafana/experimental": "2.1.5", + "@grafana/experimental": "2.1.6", "@grafana/lezer-logql": "0.2.6", "@grafana/lezer-traceql": "0.0.20", "@grafana/monaco-logql": "^0.0.7", diff --git a/public/app/plugins/datasource/zipkin/package.json b/public/app/plugins/datasource/zipkin/package.json index 6989d89c1b6..dd221998936 100644 --- a/public/app/plugins/datasource/zipkin/package.json +++ b/public/app/plugins/datasource/zipkin/package.json @@ -7,7 +7,7 @@ "@emotion/css": "11.13.5", "@grafana/data": "workspace:*", "@grafana/e2e-selectors": "workspace:*", - "@grafana/experimental": "2.1.5", + "@grafana/experimental": "2.1.6", "@grafana/o11y-ds-frontend": "workspace:*", "@grafana/runtime": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/yarn.lock b/yarn.lock index ca7bee08eed..64845369a9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2823,7 +2823,7 @@ __metadata: "@emotion/css": "npm:11.13.5" "@grafana/data": "npm:11.5.0-pre" "@grafana/e2e-selectors": "npm:11.5.0-pre" - "@grafana/experimental": "npm:2.1.5" + "@grafana/experimental": "npm:2.1.6" "@grafana/plugin-configs": "npm:11.5.0-pre" "@grafana/runtime": "npm:11.5.0-pre" "@grafana/schema": "npm:11.5.0-pre" @@ -2867,7 +2867,7 @@ __metadata: "@emotion/css": "npm:11.13.5" "@grafana/data": "npm:11.5.0-pre" "@grafana/e2e-selectors": "npm:11.5.0-pre" - "@grafana/experimental": "npm:2.1.5" + "@grafana/experimental": "npm:2.1.6" "@grafana/plugin-configs": "npm:11.5.0-pre" "@grafana/runtime": "npm:11.5.0-pre" "@grafana/sql": "npm:11.5.0-pre" @@ -2938,7 +2938,7 @@ __metadata: "@emotion/css": "npm:11.13.5" "@grafana/data": "npm:11.5.0-pre" "@grafana/e2e-selectors": "npm:11.5.0-pre" - "@grafana/experimental": "npm:2.1.5" + "@grafana/experimental": "npm:2.1.6" "@grafana/plugin-configs": "npm:11.5.0-pre" "@grafana/runtime": "npm:11.5.0-pre" "@grafana/schema": "npm:11.5.0-pre" @@ -2978,7 +2978,7 @@ __metadata: "@emotion/css": "npm:11.13.5" "@grafana/data": "workspace:*" "@grafana/e2e-selectors": "workspace:*" - "@grafana/experimental": "npm:2.1.5" + "@grafana/experimental": "npm:2.1.6" "@grafana/o11y-ds-frontend": "workspace:*" "@grafana/plugin-configs": "workspace:*" "@grafana/runtime": "workspace:*" @@ -3020,7 +3020,7 @@ __metadata: "@emotion/css": "npm:11.13.5" "@grafana/data": "npm:11.5.0-pre" "@grafana/e2e-selectors": "npm:11.5.0-pre" - "@grafana/experimental": "npm:2.1.5" + "@grafana/experimental": "npm:2.1.6" "@grafana/plugin-configs": "npm:11.5.0-pre" "@grafana/runtime": "npm:11.5.0-pre" "@grafana/sql": "npm:11.5.0-pre" @@ -3051,7 +3051,7 @@ __metadata: "@emotion/css": "npm:11.13.5" "@grafana/data": "npm:11.5.0-pre" "@grafana/e2e-selectors": "npm:11.5.0-pre" - "@grafana/experimental": "npm:2.1.5" + "@grafana/experimental": "npm:2.1.6" "@grafana/plugin-configs": "npm:11.5.0-pre" "@grafana/runtime": "npm:11.5.0-pre" "@grafana/sql": "npm:11.5.0-pre" @@ -3114,7 +3114,7 @@ __metadata: "@emotion/css": "npm:11.13.5" "@grafana/data": "npm:11.5.0-pre" "@grafana/e2e-selectors": "npm:11.5.0-pre" - "@grafana/experimental": "npm:2.1.5" + "@grafana/experimental": "npm:2.1.6" "@grafana/google-sdk": "npm:0.1.2" "@grafana/plugin-configs": "npm:11.5.0-pre" "@grafana/runtime": "npm:11.5.0-pre" @@ -3162,7 +3162,7 @@ __metadata: "@emotion/css": "npm:11.13.5" "@grafana/data": "workspace:*" "@grafana/e2e-selectors": "workspace:*" - "@grafana/experimental": "npm:2.1.5" + "@grafana/experimental": "npm:2.1.6" "@grafana/lezer-logql": "npm:0.2.6" "@grafana/lezer-traceql": "npm:0.0.20" "@grafana/monaco-logql": "npm:^0.0.7" @@ -3222,7 +3222,7 @@ __metadata: "@emotion/css": "npm:11.13.5" "@grafana/data": "workspace:*" "@grafana/e2e-selectors": "workspace:*" - "@grafana/experimental": "npm:2.1.5" + "@grafana/experimental": "npm:2.1.6" "@grafana/o11y-ds-frontend": "workspace:*" "@grafana/plugin-configs": "workspace:*" "@grafana/runtime": "workspace:*" @@ -3386,9 +3386,9 @@ __metadata: languageName: unknown linkType: soft -"@grafana/experimental@npm:2.1.5": - version: 2.1.5 - resolution: "@grafana/experimental@npm:2.1.5" +"@grafana/experimental@npm:2.1.6": + version: 2.1.6 + resolution: "@grafana/experimental@npm:2.1.6" dependencies: "@hello-pangea/dnd": "npm:^16.6.0" "@types/uuid": "npm:^8.3.3" @@ -3408,7 +3408,7 @@ __metadata: react-dom: ^18.2.0 react-select: ^5.8.0 rxjs: ^7.8.1 - checksum: 10/38a8fcb888950944e99b302b5cc35b425fc2c25b3f369cad1dba277d9c09c068814b97f1e18092698b7681c3bb7a9d561ddf5fa28f31789acb32545e352319eb + checksum: 10/3df81fef944e6dfdf843bf5954a586fe3eb90a1a2671088ae526b29fb9118793c05be96a3edd3d3babfdcbfa801fdfb328e87e6cbb1fd0de7f8f2d2e5c32f66f languageName: node linkType: hard @@ -3547,7 +3547,7 @@ __metadata: "@emotion/css": "npm:11.13.5" "@grafana/data": "npm:11.5.0-pre" "@grafana/e2e-selectors": "npm:11.5.0-pre" - "@grafana/experimental": "npm:2.1.5" + "@grafana/experimental": "npm:2.1.6" "@grafana/runtime": "npm:11.5.0-pre" "@grafana/schema": "npm:11.5.0-pre" "@grafana/tsconfig": "npm:^2.0.0" @@ -3650,7 +3650,7 @@ __metadata: "@floating-ui/react": "npm:0.27.2" "@grafana/data": "npm:11.5.0-pre" "@grafana/e2e-selectors": "npm:11.5.0-pre" - "@grafana/experimental": "npm:2.1.5" + "@grafana/experimental": "npm:2.1.6" "@grafana/faro-web-sdk": "npm:1.12.2" "@grafana/runtime": "npm:11.5.0-pre" "@grafana/schema": "npm:11.5.0-pre" @@ -3896,7 +3896,7 @@ __metadata: "@emotion/css": "npm:11.13.5" "@grafana/data": "npm:11.5.0-pre" "@grafana/e2e-selectors": "npm:11.5.0-pre" - "@grafana/experimental": "npm:2.1.5" + "@grafana/experimental": "npm:2.1.6" "@grafana/runtime": "npm:11.5.0-pre" "@grafana/tsconfig": "npm:^2.0.0" "@grafana/ui": "npm:11.5.0-pre" @@ -17539,7 +17539,7 @@ __metadata: "@grafana/e2e-selectors": "workspace:*" "@grafana/eslint-config": "npm:8.0.0" "@grafana/eslint-plugin": "link:./packages/grafana-eslint-rules" - "@grafana/experimental": "npm:2.1.5" + "@grafana/experimental": "npm:2.1.6" "@grafana/faro-core": "npm:^1.3.6" "@grafana/faro-web-sdk": "npm:^1.3.6" "@grafana/faro-web-tracing": "npm:^1.8.2" @@ -25155,7 +25155,7 @@ __metadata: languageName: node linkType: hard -"rc-util@npm:^5.0.1, rc-util@npm:^5.0.6, rc-util@npm:^5.26.0": +"rc-util@npm:^5.0.1, rc-util@npm:^5.0.6, rc-util@npm:^5.15.0, rc-util@npm:^5.16.1, rc-util@npm:^5.21.0, rc-util@npm:^5.24.4, rc-util@npm:^5.26.0, rc-util@npm:^5.27.0, rc-util@npm:^5.36.0, rc-util@npm:^5.37.0, rc-util@npm:^5.38.0, rc-util@npm:^5.38.1, rc-util@npm:^5.43.0": version: 5.44.2 resolution: "rc-util@npm:5.44.2" dependencies: @@ -25168,19 +25168,6 @@ __metadata: languageName: node linkType: hard -"rc-util@npm:^5.15.0, rc-util@npm:^5.16.1, rc-util@npm:^5.21.0, rc-util@npm:^5.24.4, rc-util@npm:^5.27.0, rc-util@npm:^5.36.0, rc-util@npm:^5.37.0, rc-util@npm:^5.38.0, rc-util@npm:^5.38.1, rc-util@npm:^5.43.0": - version: 5.43.0 - resolution: "rc-util@npm:5.43.0" - dependencies: - "@babel/runtime": "npm:^7.18.3" - react-is: "npm:^18.2.0" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10/6d5be9d79182c6b4c5a033ad6517b2940d3d2ac42a8e77ef5735591d182f8236f61bc7628d61e82a122d2046ec849462f3fe57c08d3a2a20279646785c34ec4a - languageName: node - linkType: hard - "rc-virtual-list@npm:^3.5.1, rc-virtual-list@npm:^3.5.2": version: 3.5.3 resolution: "rc-virtual-list@npm:3.5.3" From b3985a4d3765a1a4f8934b50911885588e40beeb Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Tue, 17 Dec 2024 09:35:46 -0700 Subject: [PATCH 20/26] k8s: Dashboards: allow querying of unistore (#97995) --- pkg/api/dashboard.go | 2 +- pkg/api/dashboard_test.go | 6 +- pkg/api/folder_bench_test.go | 2 +- pkg/services/dashboards/dashboard.go | 2 +- .../dashboards/dashboard_service_mock.go | 10 +- .../dashboards/service/dashboard_service.go | 434 +++++++++++++----- .../dashboard_service_integration_test.go | 5 + .../service/dashboard_service_test.go | 365 ++++++++++++--- .../service/zanzana_integration_test.go | 1 + .../service/service_test.go | 2 +- pkg/services/folder/folderimpl/folder_test.go | 7 +- .../libraryelements/libraryelements_test.go | 4 +- .../librarypanels/librarypanels_test.go | 4 +- pkg/services/ngalert/testutil/testutil.go | 2 +- .../service/dashboard_updater.go | 4 +- .../service/dashboard_updater_test.go | 22 +- .../publicdashboards/api/query_test.go | 2 +- 17 files changed, 686 insertions(+), 188 deletions(-) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 9ac9a75d9b2..59643a47956 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -478,7 +478,7 @@ func (hs *HTTPServer) deleteDashboard(c *contextmodel.ReqContext) response.Respo hs.log.Error("Failed to delete public dashboard") } - err = hs.DashboardService.DeleteDashboard(c.Req.Context(), dash.ID, c.SignedInUser.GetOrgID()) + err = hs.DashboardService.DeleteDashboard(c.Req.Context(), dash.ID, dash.UID, c.SignedInUser.GetOrgID()) if err != nil { var dashboardErr dashboards.DashboardErr if ok := errors.As(err, &dashboardErr); ok { diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index d12d8107b84..2810ffe7238 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -263,7 +263,7 @@ func TestHTTPServer_DeleteDashboardByUID_AccessControl(t *testing.T) { dashSvc := dashboards.NewFakeDashboardService(t) dashSvc.On("GetDashboard", mock.Anything, mock.Anything).Return(dash, nil).Maybe() - dashSvc.On("DeleteDashboard", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() + dashSvc.On("DeleteDashboard", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() hs.DashboardService = dashSvc hs.Cfg = setting.NewCfg() @@ -838,14 +838,14 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr if dashboardService == nil { dashboardService, err = service.ProvideDashboardServiceImpl( cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions, - ac, folderSvc, fStore, nil, zanzana.NewNoopClient(), nil, + ac, folderSvc, fStore, nil, zanzana.NewNoopClient(), nil, nil, ) require.NoError(t, err) } dashboardProvisioningService, err := service.ProvideDashboardServiceImpl( cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions, - ac, folderSvc, fStore, nil, zanzana.NewNoopClient(), nil, + ac, folderSvc, fStore, nil, zanzana.NewNoopClient(), nil, nil, ) require.NoError(t, err) diff --git a/pkg/api/folder_bench_test.go b/pkg/api/folder_bench_test.go index cfd3fb7335c..e5d186d77a9 100644 --- a/pkg/api/folder_bench_test.go +++ b/pkg/api/folder_bench_test.go @@ -478,7 +478,7 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog dashboardSvc, err := dashboardservice.ProvideDashboardServiceImpl( sc.cfg, dashStore, folderStore, features, folderPermissions, dashboardPermissions, ac, - folderServiceWithFlagOn, fStore, nil, zanzana.NewNoopClient(), nil, + folderServiceWithFlagOn, fStore, nil, zanzana.NewNoopClient(), nil, nil, ) require.NoError(b, err) diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go index 1104dc7f059..429ef8071cc 100644 --- a/pkg/services/dashboards/dashboard.go +++ b/pkg/services/dashboards/dashboard.go @@ -15,7 +15,7 @@ import ( //go:generate mockery --name DashboardService --structname FakeDashboardService --inpackage --filename dashboard_service_mock.go type DashboardService interface { BuildSaveDashboardCommand(ctx context.Context, dto *SaveDashboardDTO, validateProvisionedDashboard bool) (*SaveDashboardCommand, error) - DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error + DeleteDashboard(ctx context.Context, dashboardId int64, dashboardUID string, orgId int64) error FindDashboards(ctx context.Context, query *FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) // GetDashboard fetches a dashboard. // To fetch a dashboard under root by title should set the folder UID to point to an empty string diff --git a/pkg/services/dashboards/dashboard_service_mock.go b/pkg/services/dashboards/dashboard_service_mock.go index 6645b4cdd24..557305cc545 100644 --- a/pkg/services/dashboards/dashboard_service_mock.go +++ b/pkg/services/dashboards/dashboard_service_mock.go @@ -102,17 +102,17 @@ func (_m *FakeDashboardService) CountInFolders(ctx context.Context, orgID int64, return r0, r1 } -// DeleteDashboard provides a mock function with given fields: ctx, dashboardId, orgId -func (_m *FakeDashboardService) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error { - ret := _m.Called(ctx, dashboardId, orgId) +// DeleteDashboard provides a mock function with given fields: ctx, dashboardId, dashboardUID, orgId +func (_m *FakeDashboardService) DeleteDashboard(ctx context.Context, dashboardId int64, dashboardUID string, orgId int64) error { + ret := _m.Called(ctx, dashboardId, dashboardUID, orgId) if len(ret) == 0 { panic("no return value specified for DeleteDashboard") } var r0 error - if rf, ok := ret.Get(0).(func(context.Context, int64, int64) error); ok { - r0 = rf(ctx, dashboardId, orgId) + if rf, ok := ret.Get(0).(func(context.Context, int64, string, int64) error); ok { + r0 = rf(ctx, dashboardId, dashboardUID, orgId) } else { r0 = ret.Error(0) } diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index a4adc280c75..644607a58af 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -2,11 +2,13 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "strings" "time" + "github.com/google/uuid" "github.com/grafana/authlib/claims" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel" @@ -38,6 +40,7 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/search/model" "github.com/grafana/grafana/pkg/services/store/entity" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" k8sUser "k8s.io/apiserver/pkg/authentication/user" @@ -67,6 +70,7 @@ type DashboardServiceImpl struct { dashboardStore dashboards.Store folderStore folder.FolderStore folderService folder.Service + userService user.Service features featuremgmt.FeatureToggles folderPermissions accesscontrol.FolderPermissionsService dashboardPermissions accesscontrol.DashboardPermissionsService @@ -79,6 +83,7 @@ type DashboardServiceImpl struct { // interface to allow for testing type dashboardK8sHandler interface { getClient(ctx context.Context, orgID int64) (dynamic.ResourceInterface, bool) + getNamespace(orgID int64) string } var _ dashboardK8sHandler = (*dashk8sHandler)(nil) @@ -95,15 +100,10 @@ func ProvideDashboardServiceImpl( features featuremgmt.FeatureToggles, folderPermissionsService accesscontrol.FolderPermissionsService, dashboardPermissionsService accesscontrol.DashboardPermissionsService, ac accesscontrol.AccessControl, folderSvc folder.Service, fStore folder.Store, r prometheus.Registerer, zclient zanzana.Client, - restConfigProvider apiserver.RestConfigProvider, + restConfigProvider apiserver.RestConfigProvider, userService user.Service, ) (*DashboardServiceImpl, error) { - gvr := schema.GroupVersionResource{ - Group: v0alpha1.GROUP, - Version: v0alpha1.VERSION, - Resource: v0alpha1.DashboardResourceInfo.GetName(), - } k8sHandler := &dashk8sHandler{ - gvr: gvr, + gvr: v0alpha1.DashboardResourceInfo.GroupVersionResource(), namespacer: request.GetNamespaceMapper(cfg), restConfigProvider: restConfigProvider, } @@ -119,6 +119,7 @@ func ProvideDashboardServiceImpl( zclient: zclient, folderStore: folderStore, folderService: folderSvc, + userService: userService, k8sclient: k8sHandler, metrics: newDashboardsMetrics(r), } @@ -142,6 +143,7 @@ func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardID(ctx con } func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardUID(ctx context.Context, orgID int64, dashboardUID string) (*dashboards.DashboardProvisioning, error) { + // TODO: make this go through the k8s cli too under the feature toggle. First get dashboard through unistore & then get provisioning data return dr.dashboardStore.GetProvisionedDataByDashboardUID(ctx, orgID, dashboardUID) } @@ -284,6 +286,7 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d } func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *dashboards.DeleteOrphanedProvisionedDashboardsCommand) error { + // TODO: once we can search in unistore by id, go through k8s cli too return dr.dashboardStore.DeleteOrphanedProvisionedDashboards(ctx, cmd) } @@ -358,6 +361,7 @@ func (dr *DashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dt } // dashboard + // TODO: make this go through the k8s cli too under the feature toggle. First save dashboard & then save provisioning data dash, err := dr.dashboardStore.SaveProvisionedDashboard(ctx, *cmd, provisioning) if err != nil { return nil, err @@ -402,9 +406,9 @@ func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *dashboar return nil, err } - dash, err := dr.dashboardStore.SaveDashboard(ctx, *cmd) + dash, err := dr.saveDashboard(ctx, cmd) if err != nil { - return nil, fmt.Errorf("saving dashboard failed: %w", err) + return nil, err } // new dashboard created @@ -414,7 +418,20 @@ func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *dashboar return dash, nil } + +func (dr *DashboardServiceImpl) saveDashboard(ctx context.Context, cmd *dashboards.SaveDashboardCommand) (*dashboards.Dashboard, error) { + if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) { + return dr.saveDashboardThroughK8s(ctx, cmd, cmd.OrgID) + } + + return dr.dashboardStore.SaveDashboard(ctx, *cmd) +} + func (dr *DashboardServiceImpl) GetSoftDeletedDashboard(ctx context.Context, orgID int64, uid string) (*dashboards.Dashboard, error) { + if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) { + return dr.getDashboardThroughK8s(ctx, &dashboards.GetDashboardQuery{OrgID: orgID, UID: uid, IncludeDeleted: true}) + } + return dr.dashboardStore.GetSoftDeletedDashboard(ctx, orgID, uid) } @@ -457,6 +474,8 @@ func (dr *DashboardServiceImpl) RestoreDashboard(ctx context.Context, dashboard return folder.ErrInternal.Errorf("failed to fetch parent folder from store: %w", err) } + // TODO: once restore in k8s is finalized, add functionality here under the feature toggle + return dr.dashboardStore.RestoreDashboard(ctx, dashboard.OrgID, dashboard.UID, restoringFolder) } @@ -473,13 +492,18 @@ func (dr *DashboardServiceImpl) SoftDeleteDashboard(ctx context.Context, orgID i return dashboards.ErrDashboardCannotDeleteProvisionedDashboard } + if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) { + // deletes in unistore are soft deletes, so we can just delete in the same way + return dr.deleteDashboardThroughK8s(ctx, &dashboards.DeleteDashboardCommand{OrgID: orgID, UID: dashboardUID}) + } + return dr.dashboardStore.SoftDeleteDashboard(ctx, orgID, dashboardUID) } // DeleteDashboard removes dashboard from the DB. Errors out if the dashboard was provisioned. Should be used for // operations by the user where we want to make sure user does not delete provisioned dashboard. -func (dr *DashboardServiceImpl) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error { - return dr.deleteDashboard(ctx, dashboardId, orgId, true) +func (dr *DashboardServiceImpl) DeleteDashboard(ctx context.Context, dashboardId int64, dashboardUID string, orgId int64) error { + return dr.deleteDashboard(ctx, dashboardId, dashboardUID, orgId, true) } func (dr *DashboardServiceImpl) GetDashboardByPublicUid(ctx context.Context, dashboardPublicUid string) (*dashboards.Dashboard, error) { @@ -488,10 +512,10 @@ func (dr *DashboardServiceImpl) GetDashboardByPublicUid(ctx context.Context, das // DeleteProvisionedDashboard removes dashboard from the DB even if it is provisioned. func (dr *DashboardServiceImpl) DeleteProvisionedDashboard(ctx context.Context, dashboardId int64, orgId int64) error { - return dr.deleteDashboard(ctx, dashboardId, orgId, false) + return dr.deleteDashboard(ctx, dashboardId, "", orgId, false) } -func (dr *DashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId int64, orgId int64, validateProvisionedDashboard bool) error { +func (dr *DashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId int64, dashboardUID string, orgId int64, validateProvisionedDashboard bool) error { ctx, span := tracer.Start(ctx, "dashboards.service.deleteDashboard") defer span.End() @@ -505,7 +529,14 @@ func (dr *DashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId return dashboards.ErrDashboardCannotDeleteProvisionedDashboard } } - cmd := &dashboards.DeleteDashboardCommand{OrgID: orgId, ID: dashboardId} + + cmd := &dashboards.DeleteDashboardCommand{OrgID: orgId, ID: dashboardId, UID: dashboardUID} + + // TODO: once we can do this search by IDs in unistore, remove this constraint + if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) && cmd.UID != "" { + return dr.deleteDashboardThroughK8s(ctx, cmd) + } + return dr.dashboardStore.DeleteDashboard(ctx, cmd) } @@ -526,7 +557,7 @@ func (dr *DashboardServiceImpl) ImportDashboard(ctx context.Context, dto *dashbo return nil, err } - dash, err := dr.dashboardStore.SaveDashboard(ctx, *cmd) + dash, err := dr.saveDashboard(ctx, cmd) if err != nil { return nil, err } @@ -539,10 +570,12 @@ func (dr *DashboardServiceImpl) ImportDashboard(ctx context.Context, dto *dashbo // UnprovisionDashboard removes info about dashboard being provisioned. Used after provisioning configs are changed // and provisioned dashboards are left behind but not deleted. func (dr *DashboardServiceImpl) UnprovisionDashboard(ctx context.Context, dashboardId int64) error { + // TODO: once we can search by dashboard ID in unistore, go through k8s cli too return dr.dashboardStore.UnprovisionDashboard(ctx, dashboardId) } func (dr *DashboardServiceImpl) GetDashboardsByPluginID(ctx context.Context, query *dashboards.GetDashboardsByPluginIDQuery) ([]*dashboards.Dashboard, error) { + // TODO: once we can do this search in unistore, go through k8s cli too return dr.dashboardStore.GetDashboardsByPluginID(ctx, query) } @@ -626,92 +659,20 @@ func (dr *DashboardServiceImpl) setDefaultFolderPermissions(ctx context.Context, } } -func (dk8s *dashk8sHandler) getClient(ctx context.Context, orgID int64) (dynamic.ResourceInterface, bool) { - dyn, err := dynamic.NewForConfig(dk8s.restConfigProvider.GetRestConfig(ctx)) - if err != nil { - return nil, false - } - return dyn.Resource(dk8s.gvr).Namespace(dk8s.namespacer(orgID)), true -} - -func (dr *DashboardServiceImpl) getK8sContext(ctx context.Context) (context.Context, context.CancelFunc, error) { - requester, requesterErr := identity.GetRequester(ctx) - if requesterErr != nil { - return nil, nil, requesterErr - } - - user, exists := k8sRequest.UserFrom(ctx) - if !exists { - // add in k8s user if not there yet - var ok bool - user, ok = requester.(k8sUser.Info) - if !ok { - return nil, nil, fmt.Errorf("could not convert user to k8s user") - } - } - - newCtx := k8sRequest.WithUser(context.Background(), user) - newCtx = log.WithContextualAttributes(newCtx, log.FromContext(ctx)) - // TODO: after GLSA token workflow is removed, make this return early - // and move the else below to be unconditional - if requesterErr == nil { - newCtxWithRequester := identity.WithRequester(newCtx, requester) - newCtx = newCtxWithRequester - } - - // inherit the deadline from the original context, if it exists - deadline, ok := ctx.Deadline() - if ok { - var newCancel context.CancelFunc - newCtx, newCancel = context.WithTimeout(newCtx, time.Until(deadline)) - return newCtx, newCancel, nil - } - - return newCtx, nil, nil -} - func (dr *DashboardServiceImpl) GetDashboard(ctx context.Context, query *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error) { - // TODO: once getting dashboards by ID in unified storage is supported, we can remove the restraint of the uid being provided + // TODO: once we can do this search by ID in unistore, remove this constraint if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) && query.UID != "" { - // create a new context - prevents issues when the request stems from the k8s api itself - // otherwise the context goes through the handlers twice and causes issues - newCtx, cancel, err := dr.getK8sContext(ctx) - if err != nil { - return nil, err - } else if cancel != nil { - defer cancel() - } - - client, ok := dr.k8sclient.getClient(newCtx, query.OrgID) - if !ok { - return nil, nil - } - - // if including deleted dashboards, use the /latest subresource - subresource := "" - if query.IncludeDeleted { - subresource = "latest" - } - - out, err := client.Get(newCtx, query.UID, v1.GetOptions{}, subresource) - if err != nil { - return nil, err - } else if out == nil { - return nil, dashboards.ErrDashboardNotFound - } - - return UnstructuredToLegacyDashboard(out, query.OrgID) + return dr.getDashboardThroughK8s(ctx, query) } return dr.dashboardStore.GetDashboard(ctx, query) } -// TODO: once getting dashboards by ID in unified storage is supported, need to do the same as the above function +// TODO: once we can do this search by ID in unistore, go through k8s cli too func (dr *DashboardServiceImpl) GetDashboardUIDByID(ctx context.Context, query *dashboards.GetDashboardRefByIDQuery) (*dashboards.DashboardRef, error) { return dr.dashboardStore.GetDashboardUIDByID(ctx, query) } -// TODO: add support to get dashboards in unified storage. func (dr *DashboardServiceImpl) GetDashboards(ctx context.Context, query *dashboards.GetDashboardsQuery) ([]*dashboards.Dashboard, error) { return dr.dashboardStore.GetDashboards(ctx, query) } @@ -808,7 +769,7 @@ func (dr *DashboardServiceImpl) getUserSharedDashboardUIDs(ctx context.Context, return userDashboardUIDs, nil } -// TODO: add support to find dashboards in unified storage too. +// TODO: once we can do this search by this in unistore, go through k8s cli too func (dr *DashboardServiceImpl) FindDashboards(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { ctx, span := tracer.Start(ctx, "dashboards.service.FindDashboards") defer span.End() @@ -833,6 +794,7 @@ func (dr *DashboardServiceImpl) FindDashboards(ctx context.Context, query *dashb return dr.dashboardStore.FindDashboards(ctx, query) } +// TODO: once we can do this search in unistore, go through k8s cli too func (dr *DashboardServiceImpl) SearchDashboards(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) (model.HitList, error) { ctx, span := tracer.Start(ctx, "dashboards.service.SearchDashboards") defer span.End() @@ -854,6 +816,14 @@ func (dr *DashboardServiceImpl) SearchDashboards(ctx context.Context, query *das } func (dr *DashboardServiceImpl) GetAllDashboards(ctx context.Context) ([]*dashboards.Dashboard, error) { + if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) { + requester, err := identity.GetRequester(ctx) + if err != nil { + return nil, err + } + return dr.listDashboardThroughK8s(ctx, requester.GetOrgID()) + } + return dr.dashboardStore.GetAllDashboards(ctx) } @@ -915,6 +885,7 @@ func makeQueryResult(query *dashboards.FindPersistedDashboardsQuery, res []dashb } func (dr *DashboardServiceImpl) GetDashboardTags(ctx context.Context, query *dashboards.GetDashboardTagsQuery) ([]*dashboards.DashboardTagCloudItem, error) { + // TODO: use k8s client to get dashboards first, and then filter tags and join return dr.dashboardStore.GetDashboardTags(ctx, query) } @@ -945,7 +916,7 @@ func (dr *DashboardServiceImpl) CleanUpDeletedDashboards(ctx context.Context) (i return 0, err } for _, dashboard := range deletedDashboards { - err = dr.DeleteDashboard(ctx, dashboard.ID, dashboard.OrgID) + err = dr.DeleteDashboard(ctx, dashboard.ID, dashboard.UID, dashboard.OrgID) if err != nil { dr.log.Warn("Failed to cleanup deleted dashboard", "dashboardUid", dashboard.UID, "error", err) break @@ -956,35 +927,247 @@ func (dr *DashboardServiceImpl) CleanUpDeletedDashboards(ctx context.Context) (i return deletedDashboardsCount, nil } -func UnstructuredToLegacyDashboard(item *unstructured.Unstructured, orgID int64) (*dashboards.Dashboard, error) { +// ----------------------------------------------------------------------------------------- +// Dashboard k8s functions +// ----------------------------------------------------------------------------------------- + +func (dk8s *dashk8sHandler) getClient(ctx context.Context, orgID int64) (dynamic.ResourceInterface, bool) { + dyn, err := dynamic.NewForConfig(dk8s.restConfigProvider.GetRestConfig(ctx)) + if err != nil { + return nil, false + } + return dyn.Resource(dk8s.gvr).Namespace(dk8s.getNamespace(orgID)), true +} + +func (dk8s *dashk8sHandler) getNamespace(orgID int64) string { + return dk8s.namespacer(orgID) +} + +func (dr *DashboardServiceImpl) getK8sContext(ctx context.Context) (context.Context, context.CancelFunc, error) { + requester, requesterErr := identity.GetRequester(ctx) + if requesterErr != nil { + return nil, nil, requesterErr + } + + user, exists := k8sRequest.UserFrom(ctx) + if !exists { + // add in k8s user if not there yet + var ok bool + user, ok = requester.(k8sUser.Info) + if !ok { + return nil, nil, fmt.Errorf("could not convert user to k8s user") + } + } + + newCtx := k8sRequest.WithUser(context.Background(), user) + newCtx = log.WithContextualAttributes(newCtx, log.FromContext(ctx)) + // TODO: after GLSA token workflow is removed, make this return early + // and move the else below to be unconditional + if requesterErr == nil { + newCtxWithRequester := identity.WithRequester(newCtx, requester) + newCtx = newCtxWithRequester + } + + // inherit the deadline from the original context, if it exists + deadline, ok := ctx.Deadline() + if ok { + var newCancel context.CancelFunc + newCtx, newCancel = context.WithTimeout(newCtx, time.Until(deadline)) + return newCtx, newCancel, nil + } + + return newCtx, nil, nil +} + +func (dr *DashboardServiceImpl) getDashboardThroughK8s(ctx context.Context, query *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error) { + // create a new context - prevents issues when the request stems from the k8s api itself + // otherwise the context goes through the handlers twice and causes issues + newCtx, cancel, err := dr.getK8sContext(ctx) + if err != nil { + return nil, err + } else if cancel != nil { + defer cancel() + } + + client, ok := dr.k8sclient.getClient(newCtx, query.OrgID) + if !ok { + return nil, nil + } + + // if including deleted dashboards, use the /latest subresource + subresource := "" + if query.IncludeDeleted { + subresource = "latest" + } + + out, err := client.Get(newCtx, query.UID, v1.GetOptions{}, subresource) + if err != nil { + return nil, err + } else if out == nil { + return nil, dashboards.ErrDashboardNotFound + } + + return dr.UnstructuredToLegacyDashboard(ctx, out, query.OrgID) +} + +func (dr *DashboardServiceImpl) saveDashboardThroughK8s(ctx context.Context, cmd *dashboards.SaveDashboardCommand, orgID int64) (*dashboards.Dashboard, error) { + // create a new context - prevents issues when the request stems from the k8s api itself + // otherwise the context goes through the handlers twice and causes issues + newCtx, cancel, err := dr.getK8sContext(ctx) + if err != nil { + return nil, err + } else if cancel != nil { + defer cancel() + } + + client, ok := dr.k8sclient.getClient(newCtx, orgID) + if !ok { + return nil, nil + } + + obj, err := LegacySaveCommandToUnstructured(cmd, dr.k8sclient.getNamespace(orgID)) + if err != nil { + return nil, err + } + + var out *unstructured.Unstructured + current, err := client.Get(newCtx, obj.GetName(), v1.GetOptions{}) + if current == nil || err != nil { + out, err = client.Create(newCtx, &obj, v1.CreateOptions{}) + if err != nil { + return nil, err + } + } else { + out, err = client.Update(newCtx, &obj, v1.UpdateOptions{}) + if err != nil { + return nil, err + } + } + + finalDash, err := dr.UnstructuredToLegacyDashboard(ctx, out, orgID) + if err != nil { + return nil, err + } + + return finalDash, nil +} + +func (dr *DashboardServiceImpl) deleteDashboardThroughK8s(ctx context.Context, cmd *dashboards.DeleteDashboardCommand) error { + // create a new context - prevents issues when the request stems from the k8s api itself + // otherwise the context goes through the handlers twice and causes issues + newCtx, cancel, err := dr.getK8sContext(ctx) + if err != nil { + return err + } else if cancel != nil { + defer cancel() + } + + client, ok := dr.k8sclient.getClient(newCtx, cmd.OrgID) + if !ok { + return fmt.Errorf("could not get k8s client") + } + + err = client.Delete(newCtx, cmd.UID, v1.DeleteOptions{}) + if err != nil { + return err + } + + return nil +} + +func (dr *DashboardServiceImpl) listDashboardThroughK8s(ctx context.Context, orgID int64) ([]*dashboards.Dashboard, error) { + // create a new context - prevents issues when the request stems from the k8s api itself + // otherwise the context goes through the handlers twice and causes issues + newCtx, cancel, err := dr.getK8sContext(ctx) + if err != nil { + return nil, err + } else if cancel != nil { + defer cancel() + } + + client, ok := dr.k8sclient.getClient(newCtx, orgID) + if !ok { + return nil, nil + } + + // TODO: once we can do this search in unistore, update this + out, err := client.List(newCtx, v1.ListOptions{}) + if err != nil { + return nil, err + } else if out == nil { + return nil, dashboards.ErrDashboardNotFound + } + + dashboards := make([]*dashboards.Dashboard, 0) + for _, item := range out.Items { + dash, err := dr.UnstructuredToLegacyDashboard(ctx, &item, orgID) + if err != nil { + return nil, err + } + dashboards = append(dashboards, dash) + } + + return dashboards, nil +} + +func (dr *DashboardServiceImpl) UnstructuredToLegacyDashboard(ctx context.Context, item *unstructured.Unstructured, orgID int64) (*dashboards.Dashboard, error) { spec, ok := item.Object["spec"].(map[string]any) if !ok { return nil, errors.New("error parsing dashboard from k8s response") } - - out := dashboards.Dashboard{ - OrgID: orgID, - Data: simplejson.NewFromAny(spec), - } obj, err := utils.MetaAccessor(item) if err != nil { return nil, err } + uid := obj.GetName() + spec["uid"] = uid - out.UID = obj.GetName() - out.Slug = obj.GetSlug() - out.FolderUID = obj.GetFolder() + dashVersion := 0 + if version, ok := spec["version"].(int64); ok { + dashVersion = int(version) + } + + out := dashboards.Dashboard{ + OrgID: orgID, + UID: uid, + Slug: obj.GetSlug(), + FolderUID: obj.GetFolder(), + Version: dashVersion, + Data: simplejson.NewFromAny(spec), + } out.Created = obj.GetCreationTimestamp().Time updated, err := obj.GetUpdatedTimestamp() if err == nil && updated != nil { out.Updated = *updated + } else { + // by default, set updated to created + out.Updated = out.Created } + deleted := obj.GetDeletionTimestamp() if deleted != nil { out.Deleted = obj.GetDeletionTimestamp().Time } + createdBy := obj.GetCreatedBy() + if createdBy != "" && toUID(createdBy) != "" { + creator, err := dr.userService.GetByUID(ctx, &user.GetUserByUIDQuery{UID: toUID(createdBy)}) + if err != nil { + return nil, err + } + out.CreatedBy = creator.ID + } + + updatedBy := obj.GetUpdatedBy() + if updatedBy != "" && toUID(updatedBy) != "" { + updator, err := dr.userService.GetByUID(ctx, &user.GetUserByUIDQuery{UID: toUID(updatedBy)}) + if err != nil { + return nil, err + } + out.UpdatedBy = updator.ID + } + if id, ok := spec["id"].(int64); ok { out.ID = id } @@ -993,10 +1176,6 @@ func UnstructuredToLegacyDashboard(item *unstructured.Unstructured, orgID int64) out.GnetID = gnetID } - if version, ok := spec["version"].(int64); ok { - out.Version = int(version) - } - if pluginID, ok := spec["plugin_id"].(string); ok { out.PluginID = pluginID } @@ -1019,3 +1198,48 @@ func UnstructuredToLegacyDashboard(item *unstructured.Unstructured, orgID int64) return &out, nil } + +func LegacySaveCommandToUnstructured(cmd *dashboards.SaveDashboardCommand, namespace string) (unstructured.Unstructured, error) { + uid := cmd.GetDashboardModel().UID + if uid == "" { + uid = uuid.NewString() + } + + finalObj := unstructured.Unstructured{ + Object: map[string]interface{}{}, + } + + obj := map[string]interface{}{} + body, err := cmd.Dashboard.ToDB() + if err != nil { + return finalObj, err + } + + err = json.Unmarshal(body, &obj) + if err != nil { + return finalObj, err + } + + // update the version + version, ok := obj["version"].(float64) + if !ok || version == 0 { + obj["version"] = 1 + } else if !cmd.Overwrite { + obj["version"] = version + 1 + } + + finalObj.Object["spec"] = obj + finalObj.SetName(uid) + finalObj.SetNamespace(namespace) + finalObj.SetGroupVersionKind(v0alpha1.DashboardResourceInfo.GroupVersionKind()) + + return finalObj, nil +} + +func toUID(rawIdentifier string) string { + parts := strings.Split(rawIdentifier, ":") + if len(parts) < 2 { + return "" + } + return parts[1] +} diff --git a/pkg/services/dashboards/service/dashboard_service_integration_test.go b/pkg/services/dashboards/service/dashboard_service_integration_test.go index 8df83a2f18d..7a86c7ea47f 100644 --- a/pkg/services/dashboards/service/dashboard_service_integration_test.go +++ b/pkg/services/dashboards/service/dashboard_service_integration_test.go @@ -886,6 +886,7 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc nil, zanzana.NewNoopClient(), nil, + nil, ) require.NoError(t, err) guardian.InitAccessControlGuardian(cfg, ac, dashboardService) @@ -954,6 +955,7 @@ func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSt nil, zanzana.NewNoopClient(), nil, + nil, ) require.NoError(t, err) res, err := service.SaveDashboard(context.Background(), &dto, false) @@ -981,6 +983,7 @@ func callSaveWithError(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSto nil, zanzana.NewNoopClient(), nil, + nil, ) require.NoError(t, err) _, err = service.SaveDashboard(context.Background(), &dto, false) @@ -1027,6 +1030,7 @@ func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string nil, zanzana.NewNoopClient(), nil, + nil, ) require.NoError(t, err) res, err := service.SaveDashboard(context.Background(), &dto, false) @@ -1080,6 +1084,7 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.DB) *da nil, zanzana.NewNoopClient(), nil, + nil, ) require.NoError(t, err) res, err := service.SaveDashboard(context.Background(), &dto, false) diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index a61e821166e..5a852641cac 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -4,6 +4,7 @@ import ( "context" "reflect" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -11,6 +12,7 @@ import ( "k8s.io/client-go/dynamic" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/dashboards" @@ -19,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -179,7 +182,7 @@ func TestDashboardService(t *testing.T) { t.Run("DeleteDashboard should fail to delete it when provisioning information is missing", func(t *testing.T) { fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.AnythingOfType("int64")).Return(&dashboards.DashboardProvisioning{}, nil).Once() - err := service.DeleteDashboard(context.Background(), 1, 1) + err := service.DeleteDashboard(context.Background(), 1, "", 1) require.Equal(t, err, dashboards.ErrDashboardCannotDeleteProvisionedDashboard) }) }) @@ -196,7 +199,7 @@ func TestDashboardService(t *testing.T) { args := &dashboards.DeleteDashboardCommand{OrgID: 1, ID: 1} fakeStore.On("DeleteDashboard", mock.Anything, args).Return(nil).Once() fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.AnythingOfType("int64")).Return(nil, nil).Once() - err := service.DeleteDashboard(context.Background(), 1, 1) + err := service.DeleteDashboard(context.Background(), 1, "", 1) require.NoError(t, err) }) }) @@ -238,6 +241,10 @@ func (m *mockDashK8sCli) getClient(ctx context.Context, orgID int64) (dynamic.Re return args.Get(0).(dynamic.ResourceInterface), args.Bool(1) } +func (m *mockDashK8sCli) getNamespace(orgID int64) string { + return "default" +} + type mockResourceInterface struct { mock.Mock dynamic.ResourceInterface @@ -251,6 +258,48 @@ func (m *mockResourceInterface) Get(ctx context.Context, name string, options me return args.Get(0).(*unstructured.Unstructured), args.Error(1) } +func (m *mockResourceInterface) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { + args := m.Called(ctx, opts) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*unstructured.UnstructuredList), args.Error(1) +} + +func (m *mockResourceInterface) Create(ctx context.Context, obj *unstructured.Unstructured, options metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) { + args := m.Called(ctx, obj, options, subresources) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*unstructured.Unstructured), args.Error(1) +} + +func (m *mockResourceInterface) Update(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) { + args := m.Called(ctx, obj, options, subresources) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*unstructured.Unstructured), args.Error(1) +} + +func (m *mockResourceInterface) Delete(ctx context.Context, name string, options metav1.DeleteOptions, subresources ...string) error { + args := m.Called(ctx, name, options, subresources) + return args.Error(0) +} + +func setupK8sDashboardTests(service *DashboardServiceImpl) (context.Context, *mockDashK8sCli, *mockResourceInterface) { + k8sClientMock := new(mockDashK8sCli) + k8sResourceMock := new(mockResourceInterface) + service.k8sclient = k8sClientMock + service.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesCliDashboards) + + ctx := context.Background() + userCtx := &user.SignedInUser{UserID: 1, OrgID: 1} + ctx = identity.WithRequester(ctx, userCtx) + + return ctx, k8sClientMock, k8sResourceMock +} + func TestGetDashboard(t *testing.T) { fakeStore := dashboards.FakeDashboardStore{} defer fakeStore.AssertExpectations(t) @@ -258,14 +307,13 @@ func TestGetDashboard(t *testing.T) { cfg: setting.NewCfg(), dashboardStore: &fakeStore, } + query := &dashboards.GetDashboardQuery{ + UID: "test-uid", + OrgID: 1, + } t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { service.features = featuremgmt.WithFeatures() - query := &dashboards.GetDashboardQuery{ - UID: "test-uid", - OrgID: 1, - } - fakeStore.On("GetDashboard", mock.Anything, query).Return(&dashboards.Dashboard{}, nil).Once() dashboard, err := service.GetDashboard(context.Background(), query) require.NoError(t, err) @@ -274,36 +322,26 @@ func TestGetDashboard(t *testing.T) { }) t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { - k8sClientMock := new(mockDashK8sCli) - k8sResourceMock := new(mockResourceInterface) - service.k8sclient = k8sClientMock - service.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesCliDashboards) - query := &dashboards.GetDashboardQuery{ - UID: "test-uid", - OrgID: 1, - } - + ctx, k8sClientMock, k8sResourceMock := setupK8sDashboardTests(service) dashboardUnstructured := unstructured.Unstructured{Object: map[string]any{ "metadata": map[string]any{ "name": "uid", }, "spec": map[string]any{ - "test": "test", - "title": "testing slugify", + "test": "test", + "version": int64(1), + "title": "testing slugify", }, }} dashboardExpected := dashboards.Dashboard{ - UID: "uid", // uid is the name of the k8s object - Title: "testing slugify", - Slug: "testing-slugify", // slug is taken from title - OrgID: 1, // orgID is populated from the query - Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify"}), + UID: "uid", // uid is the name of the k8s object + Title: "testing slugify", + Slug: "testing-slugify", // slug is taken from title + OrgID: 1, // orgID is populated from the query + Version: 1, + Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}), } - - ctx := context.Background() - userCtx := &user.SignedInUser{UserID: 1} - ctx = identity.WithRequester(ctx, userCtx) k8sClientMock.On("getClient", mock.Anything, int64(1)).Return(k8sResourceMock, true).Once() k8sResourceMock.On("Get", mock.Anything, query.UID, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil).Once() @@ -316,18 +354,7 @@ func TestGetDashboard(t *testing.T) { }) t.Run("Should return error when Kubernetes client fails", func(t *testing.T) { - k8sClientMock := new(mockDashK8sCli) - k8sResourceMock := new(mockResourceInterface) - service.k8sclient = k8sClientMock - service.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesCliDashboards) - query := &dashboards.GetDashboardQuery{ - UID: "test-uid", - OrgID: 1, - } - - ctx := context.Background() - userCtx := &user.SignedInUser{UserID: 1} - ctx = identity.WithRequester(ctx, userCtx) + ctx, k8sClientMock, k8sResourceMock := setupK8sDashboardTests(service) k8sClientMock.On("getClient", mock.Anything, int64(1)).Return(k8sResourceMock, true).Once() k8sResourceMock.On("Get", mock.Anything, query.UID, mock.Anything, mock.Anything).Return(nil, assert.AnError).Once() @@ -338,21 +365,9 @@ func TestGetDashboard(t *testing.T) { }) t.Run("Should return dashboard not found if Kubernetes client returns nil", func(t *testing.T) { - k8sClientMock := new(mockDashK8sCli) - k8sResourceMock := new(mockResourceInterface) - service.k8sclient = k8sClientMock - service.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesCliDashboards) - query := &dashboards.GetDashboardQuery{ - UID: "test-uid", - OrgID: 1, - } - - ctx := context.Background() - userCtx := &user.SignedInUser{UserID: 1} - ctx = identity.WithRequester(ctx, userCtx) + ctx, k8sClientMock, k8sResourceMock := setupK8sDashboardTests(service) k8sClientMock.On("getClient", mock.Anything, int64(1)).Return(k8sResourceMock, true).Once() k8sResourceMock.On("Get", mock.Anything, query.UID, mock.Anything, mock.Anything).Return(nil, nil).Once() - dashboard, err := service.GetDashboard(ctx, query) require.Error(t, err) require.Equal(t, dashboards.ErrDashboardNotFound, err) @@ -360,3 +375,249 @@ func TestGetDashboard(t *testing.T) { k8sClientMock.AssertExpectations(t) }) } + +func TestGetAllDashboards(t *testing.T) { + fakeStore := dashboards.FakeDashboardStore{} + defer fakeStore.AssertExpectations(t) + service := &DashboardServiceImpl{ + cfg: setting.NewCfg(), + dashboardStore: &fakeStore, + } + + t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { + service.features = featuremgmt.WithFeatures() + fakeStore.On("GetAllDashboards", mock.Anything).Return([]*dashboards.Dashboard{}, nil).Once() + dashboard, err := service.GetAllDashboards(context.Background()) + require.NoError(t, err) + require.NotNil(t, dashboard) + fakeStore.AssertExpectations(t) + }) + + t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { + ctx, k8sClientMock, k8sResourceMock := setupK8sDashboardTests(service) + + dashboardUnstructured := unstructured.Unstructured{Object: map[string]any{ + "metadata": map[string]any{ + "name": "uid", + }, + "spec": map[string]any{ + "test": "test", + "version": int64(1), + "title": "testing slugify", + }, + }} + + dashboardExpected := dashboards.Dashboard{ + UID: "uid", // uid is the name of the k8s object + Title: "testing slugify", + Slug: "testing-slugify", // slug is taken from title + OrgID: 1, // orgID is populated from the query + Version: 1, // default to version 1 + Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid", "version": int64(1)}), + } + + k8sClientMock.On("getClient", mock.Anything, int64(1)).Return(k8sResourceMock, true).Once() + k8sResourceMock.On("List", mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{Items: []unstructured.Unstructured{dashboardUnstructured}}, nil).Once() + + dashes, err := service.GetAllDashboards(ctx) + require.NoError(t, err) + require.NotNil(t, dashes) + k8sClientMock.AssertExpectations(t) + // make sure the conversion is working + require.True(t, reflect.DeepEqual(dashes, []*dashboards.Dashboard{&dashboardExpected})) + }) +} + +func TestSaveDashboard(t *testing.T) { + fakeStore := dashboards.FakeDashboardStore{} + defer fakeStore.AssertExpectations(t) + service := &DashboardServiceImpl{ + cfg: setting.NewCfg(), + dashboardStore: &fakeStore, + } + + origNewDashboardGuardian := guardian.New + defer func() { guardian.New = origNewDashboardGuardian }() + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) + + query := &dashboards.SaveDashboardDTO{ + OrgID: 1, + User: &user.SignedInUser{UserID: 1}, + Dashboard: &dashboards.Dashboard{ + UID: "uid", // uid is the name of the k8s object + Title: "testing slugify", + Slug: "testing-slugify", // slug is taken from title + OrgID: 1, // orgID is populated from the query + Data: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "uid"}), + }, + } + + t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { + service.features = featuremgmt.WithFeatures() + fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.Anything).Return(nil, nil) + fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything, mock.AnythingOfType("bool")).Return(true, nil) + fakeStore.On("SaveDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil) + dashboard, err := service.SaveDashboard(context.Background(), query, false) + require.NoError(t, err) + require.NotNil(t, dashboard) + fakeStore.AssertExpectations(t) + }) + + dashboardUnstructured := unstructured.Unstructured{Object: map[string]any{ + "metadata": map[string]any{ + "name": "uid", + }, + "spec": map[string]any{ + "test": "test", + "version": int64(1), + "title": "testing slugify", + }, + }} + + t.Run("Should use Kubernetes create if feature flags are enabled and dashboard doesn't exist", func(t *testing.T) { + ctx, k8sClientMock, k8sResourceMock := setupK8sDashboardTests(service) + k8sClientMock.On("getClient", mock.Anything, int64(1)).Return(k8sResourceMock, true) + k8sResourceMock.On("Get", mock.Anything, query.Dashboard.UID, mock.Anything, mock.Anything).Return(nil, nil) + k8sResourceMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) + + dashboard, err := service.SaveDashboard(ctx, query, false) + require.NoError(t, err) + require.NotNil(t, dashboard) + }) + + t.Run("Should use Kubernetes update if feature flags are enabled and dashboard exists", func(t *testing.T) { + ctx, k8sClientMock, k8sResourceMock := setupK8sDashboardTests(service) + k8sClientMock.On("getClient", mock.Anything, int64(1)).Return(k8sResourceMock, true) + k8sResourceMock.On("Get", mock.Anything, query.Dashboard.UID, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) + k8sResourceMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil) + + dashboard, err := service.SaveDashboard(ctx, query, false) + require.NoError(t, err) + require.NotNil(t, dashboard) + }) +} + +func TestDeleteDashboard(t *testing.T) { + fakeStore := dashboards.FakeDashboardStore{} + defer fakeStore.AssertExpectations(t) + service := &DashboardServiceImpl{ + cfg: setting.NewCfg(), + dashboardStore: &fakeStore, + } + + t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { + service.features = featuremgmt.WithFeatures() + fakeStore.On("DeleteDashboard", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.Anything).Return(nil, nil).Once() + err := service.DeleteDashboard(context.Background(), 1, "uid", 1) + require.NoError(t, err) + fakeStore.AssertExpectations(t) + }) + + t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { + ctx, k8sClientMock, k8sResourceMock := setupK8sDashboardTests(service) + k8sClientMock.On("getClient", mock.Anything, int64(1)).Return(k8sResourceMock, true).Once() + fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything, mock.Anything).Return(nil, nil).Once() + k8sResourceMock.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + + err := service.DeleteDashboard(ctx, 1, "uid", 1) + require.NoError(t, err) + k8sClientMock.AssertExpectations(t) + }) +} + +func TestUnstructuredToLegacyDashboard(t *testing.T) { + fake := usertest.NewUserServiceFake() + fake.ExpectedUser = &user.User{ID: 10, UID: "useruid"} + dr := &DashboardServiceImpl{ + userService: fake, + } + t.Run("successfully converts unstructured to legacy dashboard", func(t *testing.T) { + uid := "36b7c825-79cc-435e-acf6-c78bd96a4510" + orgID := int64(123) + title := "Test Dashboard" + now := metav1.Now() + item := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "title": title, + "version": int64(1), + "id": int64(1), + }, + }, + } + + obj, err := utils.MetaAccessor(item) + require.NoError(t, err) + obj.SetCreationTimestamp(now) + obj.SetName(uid) + obj.SetCreatedBy("user:useruid") + obj.SetUpdatedBy("user:useruid") + + result, err := dr.UnstructuredToLegacyDashboard(context.Background(), item, orgID) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, uid, result.UID) + assert.Equal(t, title, result.Title) + assert.Equal(t, orgID, result.OrgID) + assert.Equal(t, "test-dashboard", result.Slug) // should slugify the title + assert.Equal(t, false, result.HasACL) + assert.Equal(t, false, result.IsFolder) + assert.Equal(t, int64(1), result.ID) + assert.Equal(t, now.Time.Format(time.RFC3339), result.Created.Format(time.RFC3339)) + assert.Equal(t, int64(10), result.CreatedBy) + assert.Equal(t, now.Time.Format(time.RFC3339), result.Updated.Format(time.RFC3339)) // updated should default to created + assert.Equal(t, int64(10), result.UpdatedBy) + }) + + t.Run("returns error if spec is missing", func(t *testing.T) { + item := &unstructured.Unstructured{ + Object: map[string]interface{}{}, + } + _, err := (&DashboardServiceImpl{}).UnstructuredToLegacyDashboard(context.Background(), item, int64(123)) + assert.Error(t, err) + assert.Equal(t, "error parsing dashboard from k8s response", err.Error()) + }) +} + +func TestLegacySaveCommandToUnstructured(t *testing.T) { + namespace := "test-namespace" + t.Run("successfully converts save command to unstructured", func(t *testing.T) { + cmd := &dashboards.SaveDashboardCommand{ + Dashboard: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "test-uid"}), + } + + result, err := LegacySaveCommandToUnstructured(cmd, namespace) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "test-uid", result.GetName()) + assert.Equal(t, "test-namespace", result.GetNamespace()) + spec := result.Object["spec"].(map[string]any) + assert.Equal(t, spec["version"], 1) + }) + + t.Run("should increase version when called", func(t *testing.T) { + cmd := &dashboards.SaveDashboardCommand{ + Dashboard: simplejson.NewFromAny(map[string]any{"test": "test", "title": "testing slugify", "uid": "test-uid", "version": int64(1)}), + } + result, err := LegacySaveCommandToUnstructured(cmd, namespace) + assert.NoError(t, err) + assert.NotNil(t, result) + spec := result.Object["spec"].(map[string]any) + assert.Equal(t, spec["version"], float64(2)) + }) +} + +func TestToUID(t *testing.T) { + t.Run("parses valid UID", func(t *testing.T) { + rawIdentifier := "user:uid-value" + result := toUID(rawIdentifier) + assert.Equal(t, "uid-value", result) + }) + + t.Run("returns empty string for invalid identifier", func(t *testing.T) { + rawIdentifier := "invalid-uid" + result := toUID(rawIdentifier) + assert.Equal(t, "", result) + }) +} diff --git a/pkg/services/dashboards/service/zanzana_integration_test.go b/pkg/services/dashboards/service/zanzana_integration_test.go index 5e71cdfb940..359b8bcebc5 100644 --- a/pkg/services/dashboards/service/zanzana_integration_test.go +++ b/pkg/services/dashboards/service/zanzana_integration_test.go @@ -74,6 +74,7 @@ func TestIntegrationDashboardServiceZanzana(t *testing.T) { nil, zclient, nil, + nil, ) require.NoError(t, err) diff --git a/pkg/services/dashboardsnapshots/service/service_test.go b/pkg/services/dashboardsnapshots/service/service_test.go index 0b55148e118..48b996c7f06 100644 --- a/pkg/services/dashboardsnapshots/service/service_test.go +++ b/pkg/services/dashboardsnapshots/service/service_test.go @@ -101,7 +101,7 @@ func TestValidateDashboardExists(t *testing.T) { feats := featuremgmt.WithFeatures() dashboardStore, err := dashdb.ProvideDashboardStore(sqlStore, cfg, feats, tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) - dashSvc, err := dashsvc.ProvideDashboardServiceImpl(cfg, dashboardStore, folderimpl.ProvideDashboardFolderStore(sqlStore), feats, nil, nil, acmock.New(), foldertest.NewFakeService(), folder.NewFakeStore(), nil, zanzana.NewNoopClient(), nil) + dashSvc, err := dashsvc.ProvideDashboardServiceImpl(cfg, dashboardStore, folderimpl.ProvideDashboardFolderStore(sqlStore), feats, nil, nil, acmock.New(), foldertest.NewFakeService(), folder.NewFakeStore(), nil, zanzana.NewNoopClient(), nil, nil) require.NoError(t, err) s := ProvideService(dsStore, secretsService, dashSvc) ctx := context.Background() diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index 10d057cfe5a..8ec7b2cc1d6 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -490,7 +490,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { CanEditValue: true, }) - dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuresFlagOn, folderPermissions, dashboardPermissions, ac, serviceWithFlagOn, nestedFolderStore, nil, zanzana.NewNoopClient(), nil) + dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuresFlagOn, folderPermissions, dashboardPermissions, ac, serviceWithFlagOn, nestedFolderStore, nil, zanzana.NewNoopClient(), nil, nil) require.NoError(t, err) alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOn, db, serviceWithFlagOn, dashSrv, ac, b) @@ -573,7 +573,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { }) dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuresFlagOff, - folderPermissions, dashboardPermissions, ac, serviceWithFlagOff, nestedFolderStore, nil, zanzana.NewNoopClient(), nil) + folderPermissions, dashboardPermissions, ac, serviceWithFlagOff, nestedFolderStore, nil, zanzana.NewNoopClient(), nil, nil) require.NoError(t, err) alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOff, db, serviceWithFlagOff, dashSrv, ac, b) @@ -719,7 +719,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { tc.service.dashboardStore = dashStore tc.service.store = nestedFolderStore - dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, tc.featuresFlag, folderPermissions, dashboardPermissions, ac, tc.service, tc.service.store, nil, zanzana.NewNoopClient(), nil) + dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, tc.featuresFlag, folderPermissions, dashboardPermissions, ac, tc.service, tc.service.store, nil, zanzana.NewNoopClient(), nil, nil) require.NoError(t, err) alertStore, err := ngstore.ProvideDBStore(cfg, tc.featuresFlag, db, tc.service, dashSrv, ac, b) require.NoError(t, err) @@ -1506,6 +1506,7 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) { nil, zanzana.NewNoopClient(), nil, + nil, ) require.NoError(t, err) diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index df88c3cfbed..b07787f0088 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -312,6 +312,7 @@ func createDashboard(t *testing.T, sqlStore db.DB, user user.SignedInUser, dash nil, zanzana.NewNoopClient(), nil, + nil, ) require.NoError(t, err) dashboard, err := service.SaveDashboard(context.Background(), dashItem, true) @@ -400,6 +401,7 @@ func scenarioWithPanel(t *testing.T, desc string, fn func(t *testing.T, sc scena foldertest.NewFakeService(), folder.NewFakeStore(), nil, zanzana.NewNoopClient(), nil, + nil, ) require.NoError(t, svcErr) guardian.InitAccessControlGuardian(cfg, ac, dashboardService) @@ -461,7 +463,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions, ac, foldertest.NewFakeService(), folder.NewFakeStore(), - nil, zanzana.NewNoopClient(), nil, + nil, zanzana.NewNoopClient(), nil, nil, ) require.NoError(t, dashSvcErr) guardian.InitAccessControlGuardian(cfg, ac, dashService) diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index 818a6ee3a66..c51d510fbd5 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -736,7 +736,7 @@ func createDashboard(t *testing.T, sqlStore db.DB, user *user.SignedInUser, dash cfg, dashboardStore, folderStore, featuremgmt.WithFeatures(), acmock.NewMockedPermissionsService(), dashPermissionService, ac, foldertest.NewFakeService(), folder.NewFakeStore(), - nil, zanzana.NewNoopClient(), nil, + nil, zanzana.NewNoopClient(), nil, nil, ) require.NoError(t, err) dashboard, err := service.SaveDashboard(context.Background(), dashItem, true) @@ -833,7 +833,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo cfg, dashStore, folderStore, features, acmock.NewMockedPermissionsService(), dashPermissionService, ac, foldertest.NewFakeService(), folder.NewFakeStore(), - nil, zanzana.NewNoopClient(), nil, + nil, zanzana.NewNoopClient(), nil, nil, ) require.NoError(t, err) guardian.InitAccessControlGuardian(cfg, ac, dashService) diff --git a/pkg/services/ngalert/testutil/testutil.go b/pkg/services/ngalert/testutil/testutil.go index 2c832919ba8..3f319841ffd 100644 --- a/pkg/services/ngalert/testutil/testutil.go +++ b/pkg/services/ngalert/testutil/testutil.go @@ -62,7 +62,7 @@ func SetupDashboardService(tb testing.TB, sqlStore db.DB, fs *folderimpl.Dashboa cfg, dashboardStore, fs, features, folderPermissions, dashboardPermissions, ac, foldertest.NewFakeService(), folder.NewFakeStore(), - nil, zanzana.NewNoopClient(), nil, + nil, zanzana.NewNoopClient(), nil, nil, ) require.NoError(tb, err) diff --git a/pkg/services/plugindashboards/service/dashboard_updater.go b/pkg/services/plugindashboards/service/dashboard_updater.go index 3f04666175c..7cf8bc8c46d 100644 --- a/pkg/services/plugindashboards/service/dashboard_updater.go +++ b/pkg/services/plugindashboards/service/dashboard_updater.go @@ -95,7 +95,7 @@ func (du *DashboardUpdater) syncPluginDashboards(ctx context.Context, plugin plu if dash.Removed { du.logger.Info("Deleting plugin dashboard", "pluginId", plugin.ID, "dashboard", dash.Slug) - if err := du.dashboardService.DeleteDashboard(ctx, dash.DashboardId, orgID); err != nil { + if err := du.dashboardService.DeleteDashboard(ctx, dash.DashboardId, dash.UID, orgID); err != nil { du.logger.Error("Failed to auto update app dashboard", "pluginId", plugin.ID, "error", err) return } @@ -150,7 +150,7 @@ func (du *DashboardUpdater) handlePluginStateChanged(ctx context.Context, event for _, dash := range queryResult { du.logger.Info("Deleting plugin dashboard", "pluginId", event.PluginId, "dashboard", dash.Slug) - if err := du.dashboardService.DeleteDashboard(ctx, dash.ID, dash.OrgID); err != nil { + if err := du.dashboardService.DeleteDashboard(ctx, dash.ID, dash.UID, dash.OrgID); err != nil { return err } } diff --git a/pkg/services/plugindashboards/service/dashboard_updater_test.go b/pkg/services/plugindashboards/service/dashboard_updater_test.go index 6a8010c39f4..e92fa23ed11 100644 --- a/pkg/services/plugindashboards/service/dashboard_updater_test.go +++ b/pkg/services/plugindashboards/service/dashboard_updater_test.go @@ -443,18 +443,21 @@ func (s *pluginsSettingsServiceMock) DecryptedValues(_ *pluginsettings.DTO) map[ type dashboardServiceMock struct { dashboards.DashboardService deleteDashboardArgs []struct { - orgId int64 - dashboardId int64 + orgId int64 + dashboardId int64 + dashboardUID string } } -func (s *dashboardServiceMock) DeleteDashboard(_ context.Context, dashboardId int64, orgId int64) error { +func (s *dashboardServiceMock) DeleteDashboard(_ context.Context, dashboardId int64, dashboardUID string, orgId int64) error { s.deleteDashboardArgs = append(s.deleteDashboardArgs, struct { - orgId int64 - dashboardId int64 + orgId int64 + dashboardId int64 + dashboardUID string }{ - orgId: orgId, - dashboardId: dashboardId, + orgId: orgId, + dashboardId: dashboardId, + dashboardUID: dashboardUID, }) return nil } @@ -532,8 +535,9 @@ func scenario(t *testing.T, desc string, input scenarioInput, f func(ctx *scenar sCtx.dashboardService = &dashboardServiceMock{ deleteDashboardArgs: []struct { - orgId int64 - dashboardId int64 + orgId int64 + dashboardId int64 + dashboardUID string }{}, } diff --git a/pkg/services/publicdashboards/api/query_test.go b/pkg/services/publicdashboards/api/query_test.go index 58259f53ff7..775f367d516 100644 --- a/pkg/services/publicdashboards/api/query_test.go +++ b/pkg/services/publicdashboards/api/query_test.go @@ -326,7 +326,7 @@ func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T) dashService, err := service.ProvideDashboardServiceImpl( cfg, dashboardStoreService, folderStore, featuremgmt.WithFeatures(), acmock.NewMockedPermissionsService(), dashPermissionService, ac, - foldertest.NewFakeService(), folder.NewFakeStore(), nil, zanzana.NewNoopClient(), nil, + foldertest.NewFakeService(), folder.NewFakeStore(), nil, zanzana.NewNoopClient(), nil, nil, ) require.NoError(t, err) From 8d4e5a4e0969aa4cb11af66a7d43f166e0718f15 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Dec 2024 16:42:13 +0000 Subject: [PATCH 21/26] Update dependency @types/dompurify to v3.2.0 (#98122) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> From 03d176fae4171968815a426351b6670e382f0b31 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Tue, 17 Dec 2024 11:48:39 -0600 Subject: [PATCH 22/26] Tooltips: Generate data links in TooltipPlugin2 (#97818) Co-authored-by: Leon Sorokin --- .../VizTooltip/VizTooltipFooter.tsx | 2 +- .../uPlot/plugins/TooltipPlugin2.tsx | 30 +++++++++++++++++-- .../plugins/panel/barchart/BarChartPanel.tsx | 6 +++- .../panel/candlestick/CandlestickPanel.tsx | 6 +++- .../state-timeline/StateTimelinePanel.tsx | 6 +++- .../state-timeline/StateTimelineTooltip2.tsx | 6 ++-- .../status-history/StatusHistoryPanel.tsx | 6 +++- .../panel/timeseries/TimeSeriesPanel.tsx | 6 +++- .../panel/timeseries/TimeSeriesTooltip.tsx | 9 +++--- public/app/plugins/panel/trend/TrendPanel.tsx | 6 +++- 10 files changed, 66 insertions(+), 17 deletions(-) diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx index b41fb29c944..05b05f7518d 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx @@ -40,7 +40,7 @@ export const VizTooltipFooter = ({ dataLinks, actions, annotate }: VizTooltipFoo return (
- {dataLinks.length > 0 &&
{renderDataLinks(dataLinks, styles)}
} + {dataLinks?.length > 0 &&
{renderDataLinks(dataLinks, styles)}
} {actions && actions.length > 0 &&
{renderActions(actions)}
} {annotate != null && (
diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx index 54b6648efef..04756f2f924 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { createPortal } from 'react-dom'; import uPlot from 'uplot'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, LinkModel } from '@grafana/data'; import { DashboardCursorSync } from '@grafana/schema'; import { useStyles2 } from '../../../themes'; @@ -27,6 +27,8 @@ export const enum TooltipHoverMode { xyOne, } +type GeDataLinksCallback = (seriesIdx: number, dataIdx: number) => LinkModel[]; + interface TooltipPlugin2Props { config: UPlotConfigBuilder; hoverMode: TooltipHoverMode; @@ -40,6 +42,7 @@ interface TooltipPlugin2Props { clientZoom?: boolean; onSelectRange?: OnSelectRangeCallback; + getDataLinks?: GeDataLinksCallback; render: ( u: uPlot, @@ -49,7 +52,8 @@ interface TooltipPlugin2Props { dismiss: () => void, // selected time range (for annotation triggering) timeRange: TimeRange2 | null, - viaSync: boolean + viaSync: boolean, + dataLinks: LinkModel[] ) => React.ReactNode; maxWidth?: number; @@ -102,6 +106,8 @@ const MIN_ZOOM_DIST = 5; const maybeZoomAction = (e?: MouseEvent | null) => e != null && !e.ctrlKey && !e.metaKey; +const getDataLinksFallback: GeDataLinksCallback = () => []; + /** * @alpha */ @@ -115,6 +121,7 @@ export const TooltipPlugin2 = ({ maxWidth, syncMode = DashboardCursorSync.Off, syncScope = 'global', // eventsScope + getDataLinks = getDataLinksFallback, }: TooltipPlugin2Props) => { const domRef = useRef(null); const portalRoot = useRef(null); @@ -131,6 +138,9 @@ export const TooltipPlugin2 = ({ const renderRef = useRef(render); renderRef.current = render; + const getLinksRef = useRef(getDataLinks); + getLinksRef.current = getDataLinks; + useLayoutEffect(() => { sizeRef.current = { width: 0, @@ -187,6 +197,7 @@ export const TooltipPlugin2 = ({ let seriesIdxs: Array = plot?.cursor.idxs!.slice()!; let closestSeriesIdx: number | null = null; let viaSync = false; + let dataLinks: LinkModel[] = []; let pendingRender = false; let pendingPinned = false; @@ -242,7 +253,16 @@ export const TooltipPlugin2 = ({ isHovering: _isHovering, contents: _isHovering || selectedRange != null - ? renderRef.current(_plot!, seriesIdxs, closestSeriesIdx, _isPinned, dismiss, selectedRange, viaSync) + ? renderRef.current( + _plot!, + seriesIdxs, + closestSeriesIdx, + _isPinned, + dismiss, + selectedRange, + viaSync, + dataLinks + ) : null, dismiss, }; @@ -257,6 +277,8 @@ export const TooltipPlugin2 = ({ _isPinned = false; _isHovering = false; _plot!.setCursor({ left: -10, top: -10 }); + dataLinks = []; + scheduleRender(prevIsPinned); }; @@ -313,6 +335,8 @@ export const TooltipPlugin2 = ({ } // only pinnable tooltip is visible *and* is within proximity to series/point else if (_isHovering && closestSeriesIdx != null && !_isPinned) { + dataLinks = getLinksRef.current(closestSeriesIdx!, seriesIdxs[closestSeriesIdx!]!); + setTimeout(() => { _isPinned = true; scheduleRender(true); diff --git a/public/app/plugins/panel/barchart/BarChartPanel.tsx b/public/app/plugins/panel/barchart/BarChartPanel.tsx index ce83432e202..5b03f0fbc50 100644 --- a/public/app/plugins/panel/barchart/BarChartPanel.tsx +++ b/public/app/plugins/panel/barchart/BarChartPanel.tsx @@ -157,7 +157,10 @@ export const BarChartPanel = (props: PanelProps) => { hoverMode={ options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll } - render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2) => { + getDataLinks={(seriesIdx: number, dataIdx: number) => + vizSeries[0].fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? [] + } + render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => { return ( ) => { isPinned={isPinned} maxHeight={options.tooltip.maxHeight} replaceVariables={replaceVariables} + dataLinks={dataLinks} /> ); }} diff --git a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx index acddd0a5727..4ed97d59456 100644 --- a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx +++ b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx @@ -282,7 +282,10 @@ export const CandlestickPanel = ({ clientZoom={true} syncMode={cursorSync} syncScope={eventsScope} - render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2, viaSync) => { + getDataLinks={(seriesIdx: number, dataIdx: number) => + alignedFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? [] + } + render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2, viaSync, dataLinks) => { if (enableAnnotationCreation && timeRange2 != null) { setNewAnnotationRange(timeRange2); dismiss(); @@ -307,6 +310,7 @@ export const CandlestickPanel = ({ annotate={enableAnnotationCreation ? annotate : undefined} maxHeight={options.tooltip.maxHeight} replaceVariables={replaceVariables} + dataLinks={dataLinks} /> ); }} diff --git a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx index df2df2c811c..81e272a6b98 100644 --- a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx @@ -172,7 +172,10 @@ export const StateTimelinePanel = ({ queryZoom={onChangeTimeRange} syncMode={cursorSync} syncScope={eventsScope} - render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => { + getDataLinks={(seriesIdx: number, dataIdx: number) => + alignedFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? [] + } + render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => { if (enableAnnotationCreation && timeRange2 != null) { setNewAnnotationRange(timeRange2); dismiss(); @@ -199,6 +202,7 @@ export const StateTimelinePanel = ({ withDuration={true} maxHeight={options.tooltip.maxHeight} replaceVariables={replaceVariables} + dataLinks={dataLinks} /> ); }} diff --git a/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx b/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx index d4df7d88187..a2f2665a11d 100644 --- a/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx @@ -11,7 +11,7 @@ import { VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types'; import { getContentItems } from '@grafana/ui/src/components/VizTooltip/utils'; import { findNextStateIndex, fmtDuration } from 'app/core/components/TimelineChart/utils'; -import { getDataLinks, getFieldActions } from '../status-history/utils'; +import { getFieldActions } from '../status-history/utils'; import { TimeSeriesTooltipProps } from '../timeseries/TimeSeriesTooltip'; import { isTooltipScrollable } from '../timeseries/utils'; @@ -32,6 +32,7 @@ export const StateTimelineTooltip2 = ({ withDuration, maxHeight, replaceVariables, + dataLinks, }: StateTimelineTooltip2Props) => { const xField = series.fields[0]; @@ -70,10 +71,9 @@ export const StateTimelineTooltip2 = ({ if (isPinned && seriesIdx != null) { const field = series.fields[seriesIdx]; const dataIdx = dataIdxs[seriesIdx]!; - const links = getDataLinks(field, dataIdx); const actions = getFieldActions(series, field, replaceVariables!, dataIdx); - footer = ; + footer = ; } const headerItem: VizTooltipItem = { diff --git a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx index 5f09c96ca52..fd1164c85f3 100644 --- a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx +++ b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx @@ -104,7 +104,10 @@ export const StatusHistoryPanel = ({ queryZoom={onChangeTimeRange} syncMode={cursorSync} syncScope={eventsScope} - render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => { + getDataLinks={(seriesIdx: number, dataIdx: number) => + alignedFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? [] + } + render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => { if (enableAnnotationCreation && timeRange2 != null) { setNewAnnotationRange(timeRange2); dismiss(); @@ -131,6 +134,7 @@ export const StatusHistoryPanel = ({ withDuration={false} maxHeight={options.tooltip.maxHeight} replaceVariables={replaceVariables} + dataLinks={dataLinks} /> ); }} diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index fb9076f8a01..9cd2ccf97a3 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -106,7 +106,10 @@ export const TimeSeriesPanel = ({ clientZoom={true} syncMode={cursorSync} syncScope={eventsScope} - render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2, viaSync) => { + getDataLinks={(seriesIdx: number, dataIdx: number) => + alignedFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? [] + } + render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2, viaSync, dataLinks) => { if (enableAnnotationCreation && timeRange2 != null) { setNewAnnotationRange(timeRange2); dismiss(); @@ -132,6 +135,7 @@ export const TimeSeriesPanel = ({ annotate={enableAnnotationCreation ? annotate : undefined} maxHeight={options.tooltip.maxHeight} replaceVariables={replaceVariables} + dataLinks={dataLinks} /> ); }} diff --git a/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx b/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx index 5ea7e95fe31..43efd330f17 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx @@ -1,6 +1,6 @@ import { ReactNode } from 'react'; -import { DataFrame, Field, FieldType, formattedValueToString, InterpolateFunction } from '@grafana/data'; +import { DataFrame, Field, FieldType, formattedValueToString, InterpolateFunction, LinkModel } from '@grafana/data'; import { SortOrder, TooltipDisplayMode } from '@grafana/schema/dist/esm/common/common.gen'; import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent'; import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter'; @@ -9,7 +9,7 @@ import { VizTooltipWrapper } from '@grafana/ui/src/components/VizTooltip/VizTool import { VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types'; import { getContentItems } from '@grafana/ui/src/components/VizTooltip/utils'; -import { getDataLinks, getFieldActions } from '../status-history/utils'; +import { getFieldActions } from '../status-history/utils'; import { fmt } from '../xychart/utils'; import { isTooltipScrollable } from './utils'; @@ -37,6 +37,7 @@ export interface TimeSeriesTooltipProps { maxHeight?: number; replaceVariables?: InterpolateFunction; + dataLinks: LinkModel[]; } export const TimeSeriesTooltip = ({ @@ -50,6 +51,7 @@ export const TimeSeriesTooltip = ({ annotate, maxHeight, replaceVariables, + dataLinks, }: TimeSeriesTooltipProps) => { const xField = series.fields[0]; const xVal = formattedValueToString(xField.display!(xField.values[dataIdxs[0]!])); @@ -78,10 +80,9 @@ export const TimeSeriesTooltip = ({ if (isPinned && seriesIdx != null) { const field = series.fields[seriesIdx]; const dataIdx = dataIdxs[seriesIdx]!; - const links = getDataLinks(field, dataIdx); const actions = getFieldActions(series, field, replaceVariables!, dataIdx); - footer = ; + footer = ; } const headerItem: VizTooltipItem | null = xField.config.custom?.hideFrom?.tooltip diff --git a/public/app/plugins/panel/trend/TrendPanel.tsx b/public/app/plugins/panel/trend/TrendPanel.tsx index b787f799d92..c9175dfb246 100644 --- a/public/app/plugins/panel/trend/TrendPanel.tsx +++ b/public/app/plugins/panel/trend/TrendPanel.tsx @@ -119,7 +119,10 @@ export const TrendPanel = ({ hoverMode={ options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll } - render={(u, dataIdxs, seriesIdx, isPinned = false) => { + getDataLinks={(seriesIdx: number, dataIdx: number) => + alignedDataFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? [] + } + render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange, viaSync, dataLinks) => { return ( ); }} From 8c03caad8831ef5ab35dcff4bbe53583ee4d2138 Mon Sep 17 00:00:00 2001 From: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Tue, 17 Dec 2024 11:52:26 -0600 Subject: [PATCH 23/26] Table panel: expose TableCellInspector and TableCellInspectorMode in @grafana/ui package (#98125) chore: expose TableCellInspector and TableCellInspectorMode --- packages/grafana-ui/README.md | 2 +- packages/grafana-ui/src/components/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/grafana-ui/README.md b/packages/grafana-ui/README.md index 7c362502556..745218cb110 100644 --- a/packages/grafana-ui/README.md +++ b/packages/grafana-ui/README.md @@ -16,4 +16,4 @@ See [package source](https://github.com/grafana/grafana/tree/main/packages/grafa ## Development -For development purposes we suggest using `yarn link` that will create symlink to @grafana/ui lib. To do so navigate to `packages/grafana-ui` and run `yarn link`. Then, navigate to your project and run `yarn link @grafana/ui` to use the linked version of the lib. To unlink follow the same procedure, but use `yarn unlink` instead. +For development purposes we suggest using `yarn link` that will create symlink to @grafana/ui lib. To do so navigate to `packages/grafana-ui` and run `YARN_IGNORE_PATH=1 yarn link`. Then, navigate to your project and run `yarn link "@grafana/ui"` to use the linked version of the lib. To unlink follow the same procedure, but use `yarn unlink` instead. diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index ec327ebf293..e52f47835f5 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -85,6 +85,7 @@ export { PageToolbar } from './PageLayout/PageToolbar'; export { SetInterval } from './SetInterval/SetInterval'; export { Table } from './Table/Table'; +export { TableCellInspector, TableCellInspectorMode } from './Table/TableCellInspector'; export { type TableCustomCellOptions, type CustomCellRendererProps, From 7bdf521ba3f4ff1f41060938b49f8f24d9ba3043 Mon Sep 17 00:00:00 2001 From: Ezequiel Victorero Date: Tue, 17 Dec 2024 15:08:35 -0300 Subject: [PATCH 24/26] Share: Add invite user to the sharing menu (#97067) Co-authored-by: Juan Cabanas --- .../src/selectors/pages.ts | 3 + .../sharing/ShareButton/ShareMenu.test.tsx | 22 ++++++- .../sharing/ShareButton/ShareMenu.tsx | 63 +++++++++++++++---- .../dashboard/components/ShareModal/utils.ts | 1 + public/locales/en-US/grafana.json | 1 + public/locales/pseudo-LOCALE/grafana.json | 1 + 6 files changed, 78 insertions(+), 13 deletions(-) diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 29c310d6aff..45ab6518453 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -206,6 +206,9 @@ export const versionedPages = { shareSnapshot: { '11.2.0': 'data-testid new share button share snapshot', }, + inviteUser: { + '11.5.0': 'data-testid new share button invite user', + }, }, }, NewExportButton: { diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx index 54b0f3bcabc..5d865ce2b4c 100644 --- a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx @@ -31,15 +31,17 @@ describe('ShareMenu', () => { Object.defineProperty(contextSrv, 'isSignedIn', { value: true, }); - grantUserPermissions([AccessControlAction.SnapshotsCreate]); + grantUserPermissions([AccessControlAction.SnapshotsCreate, AccessControlAction.OrgUsersAdd]); config.publicDashboardsEnabled = true; config.snapshotEnabled = true; + config.externalUserMngLinkUrl = 'http://localhost:3000'; setup({ meta: { canEdit: true } }); expect(await screen.findByTestId(selector.shareInternally)).toBeInTheDocument(); expect(await screen.findByTestId(selector.shareExternally)).toBeInTheDocument(); expect(await screen.findByTestId(selector.shareSnapshot)).toBeInTheDocument(); + expect(await screen.findByTestId(selector.inviteUser)).toBeInTheDocument(); }); it('should not share externally when public dashboard is disabled', async () => { @@ -49,6 +51,24 @@ describe('ShareMenu', () => { expect(screen.queryByTestId(selector.shareExternally)).not.toBeInTheDocument(); }); + it('should not render invite user when user does not have access', async () => { + Object.defineProperty(contextSrv, 'isSignedIn', { + value: true, + }); + + expect(await screen.queryByTestId(selector.inviteUser)).not.toBeInTheDocument(); + }); + + it('should not render invite user when externalUserMngLinkUrl is not provided', async () => { + Object.defineProperty(contextSrv, 'isSignedIn', { + value: true, + }); + grantUserPermissions([AccessControlAction.OrgUsersAdd]); + config.externalUserMngLinkUrl = ''; + + expect(await screen.queryByTestId(selector.inviteUser)).not.toBeInTheDocument(); + }); + describe('ShareSnapshot', () => { it('should not share snapshot when user is not signed in', async () => { config.snapshotEnabled = true; diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx index 0196ee754b8..c701e2a41ec 100644 --- a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx @@ -1,9 +1,12 @@ +import { css } from '@emotion/css'; import { useCallback } from 'react'; +import * as React from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { config, locationService } from '@grafana/runtime'; import { VizPanel } from '@grafana/scenes'; -import { IconName, Menu } from '@grafana/ui'; +import { Icon, IconName, Menu, useStyles2 } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; import { t } from 'app/core/internationalization'; import { AccessControlAction } from 'app/types'; @@ -23,6 +26,9 @@ export interface ShareDrawerMenuItem { icon: IconName; renderCondition: boolean; onClick: (d: DashboardScene) => void; + renderDividerAbove?: boolean; + component?: React.ComponentType; + className?: string; } let customShareDrawerItems: ShareDrawerMenuItem[] = []; @@ -36,6 +42,7 @@ export function resetDashboardShareDrawerItems() { } export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardScene; panel?: VizPanel }) { + const styles = useStyles2(getStyles); const onMenuItemClick = (shareView: string) => { locationService.partial({ shareView }); }; @@ -63,8 +70,6 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc }, }); - customShareDrawerItems.forEach((d) => menuItems.push(d)); - menuItems.push({ shareId: shareDashboardType.snapshot, testId: newShareButtonSelector.shareSnapshot, @@ -79,8 +84,24 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc }, }); + customShareDrawerItems.forEach((d) => menuItems.push(d)); + + menuItems.push({ + shareId: shareDashboardType.inviteUser, + testId: newShareButtonSelector.inviteUser, + icon: 'add-user', + label: t('share-dashboard.menu.invite-user-title', 'Invite new member'), + renderCondition: !!config.externalUserMngLinkUrl && contextSrv.hasPermission(AccessControlAction.OrgUsersAdd), + onClick: () => { + window.open(config.externalUserMngLinkUrl, '_blank'); + }, + renderDividerAbove: true, + component: () => , + className: styles.inviteUserItem, + }); + return menuItems.filter((item) => item.renderCondition); - }, [panel]); + }, [panel, styles]); const onClick = (item: ShareDrawerMenuItem) => { DashboardInteractions.sharingCategoryClicked({ @@ -94,15 +115,33 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc return ( {buildMenuItems().map((item) => ( - onClick(item)} - /> + + {item.renderDividerAbove && } + onClick(item)} + /> + ))} ); } + +const getStyles = (theme: GrafanaTheme2) => { + return { + inviteUserItem: css({ + display: 'flex', + justifyContent: 'start', + flexDirection: 'row', + alignItems: 'center', + }), + inviteUserItemIcon: css({ + color: theme.colors.text.link, + }), + }; +}; diff --git a/public/app/features/dashboard/components/ShareModal/utils.ts b/public/app/features/dashboard/components/ShareModal/utils.ts index ce8c5f5b168..bcf6925da56 100644 --- a/public/app/features/dashboard/components/ShareModal/utils.ts +++ b/public/app/features/dashboard/components/ShareModal/utils.ts @@ -180,4 +180,5 @@ export const shareDashboardType: { pdf: 'pdf', report: 'report', publicDashboard: 'public_dashboard', + inviteUser: 'invite_user', }; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 949a8a0ddee..9c85e1f761e 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2919,6 +2919,7 @@ "share-dashboard": { "menu": { "export-json-title": "Export as JSON", + "invite-user-title": "Invite new member", "share-externally-title": "Share externally", "share-internally-title": "Share internally", "share-snapshot-title": "Share snapshot" diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 30a74c4eec8..59c48e08498 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -2919,6 +2919,7 @@ "share-dashboard": { "menu": { "export-json-title": "Ēχpőřŧ äş ĴŜØŃ", + "invite-user-title": "Ĩʼnvįŧę ʼnęŵ męmþęř", "share-externally-title": "Ŝĥäřę ęχŧęřʼnäľľy", "share-internally-title": "Ŝĥäřę įʼnŧęřʼnäľľy", "share-snapshot-title": "Ŝĥäřę şʼnäpşĥőŧ" From 31e67b5ba36a1b310ac2cc72d5e2ebb313f1b305 Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Tue, 17 Dec 2024 14:10:09 -0500 Subject: [PATCH 25/26] Docs: Add invite new member section (#97967) --- .../share-dashboards-panels/_index.md | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/sources/dashboards/share-dashboards-panels/_index.md b/docs/sources/dashboards/share-dashboards-panels/_index.md index 4bf68d24480..ef8e67c1bb0 100644 --- a/docs/sources/dashboards/share-dashboards-panels/_index.md +++ b/docs/sources/dashboards/share-dashboards-panels/_index.md @@ -66,9 +66,9 @@ Grafana enables you to share dashboards and panels with other users within your - Reports - Library panels -You must have an authorized viewer permission to see an image rendered by a direct link. +You can also invite new members to your organization from the **Share** menu. For more information, refer to [Invite new members](#invite-new-members). -The same permission is also required to view embedded links unless you have anonymous access permission enabled for your Grafana instance. +You must have an authorized viewer permission to see an image rendered by a direct link. The same permission is also required to view embedded links unless you have anonymous access permission enabled for your Grafana instance. {{< admonition type="note" >}} Anonymous access permission is not available in Grafana Cloud. This feature is only supported for Grafana Enterprise and Grafana Open Source. @@ -341,3 +341,23 @@ To delete existing snapshots, follow these steps: 1. Click the red **x** next to the snapshot URL that you want to delete. The snapshot is immediately deleted. You may need to clear your browser cache or use a private or incognito browser to confirm this. + +## Invite new members + +{{< admonition type="note" >}} +This feature is only available on Grafana Cloud. +{{< /admonition >}} + +You can invite new members to your organization using the **Share** drop-down menu. You must have the `OrgUsersAdd` permission to use this feature. + +To invite a new member to your organization, follow these steps: + +1. Click **Dashboards** in the main menu and open any dashboard. +1. Click the **Share** drop-down list in the top-right corner and select **Invite new member**. + + The **Members** page of your Grafana Cloud Portal opens. + +1. Enter the email address of the new member in the provided field. +1. Make a selection in the **Role** drop-down list. +1. (Optional) Select the **Receive billing emails** checkbox, if applicable. +1. Click **Invite**. From 070f0e4457c5967102ef157197073dc2662f6fb8 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Tue, 17 Dec 2024 22:17:09 +0100 Subject: [PATCH 26/26] Dashboard API versions handling (#96666) * structure apic * API versioning proposal * Make api service independent from version * Update v2 * Fix public dashboards page test * Uncomment reload dashboard feature code * Revert * Betterer * Fix imports * useV2DashboardsAPI feature toggle * POC/v2 schema: Add v1<-> v2 transformers (#97058) * Make dshboard access interface more precise * Add first pass for schema v1<->v2 transformers * Update response transformer test * Import fixes * Manage dashboards validation: Handle v2 schema * Handle new dashboard with v2 * Fix tests * Move dashboard is folder error handling to legacy API implementation * Add tests for dashboard api client * betterer * Use dashboard DTO when capturing dashbaord impression * prettier * Dashboard API: resolve folder metadata * Add tests for resolving folder metadata * Fix DashboardPicker * Renames and nits * POC Alternative Suggestion for Dashboard API versions handling (#97789) * Add transitional_dashboard_api, reset components that are not ready for v2 schema, and start working on migrating DashboardPicker to use v2 schema * reset DashboardScenePageStateManager * Improve logic in transitional api, also remove isDashboardResource checks from components * REmove transitional_dashboard_api and apply PR feedback * Apply PR feedback, use 'v2' as a parameter and remove unnecesary if * Fix tests * Adding missing comments from original PR and also changing order to improve diffing in github :) * update betterer * fix prettier * Add tests for DashboardPicker * Do not use unified alerting mocks * Fix unused type in dashboard test * Improve comments in DahboardPicker * Update folder validation fn * Validation update * Update legacy api test * Lint --------- Co-authored-by: alexandra vargas Co-authored-by: Alexa V <239999+axelavargas@users.noreply.github.com> Co-authored-by: Ivan Ortega --- .betterer.results | 7 +- .../feature-toggles/index.md | 1 + .../src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 13 ++ .../Select/DashboardPicker.test.tsx | 135 +++++++++++ .../components/Select/DashboardPicker.tsx | 35 ++- public/app/features/apiserver/client.ts | 3 +- public/app/features/apiserver/types.ts | 21 +- .../buildNewDashboardSaveModel.test.ts | 32 ++- .../buildNewDashboardSaveModel.ts | 83 +++++++ .../transformSaveModelSchemaV2ToScene.test.ts | 2 +- .../transformSaveModelSchemaV2ToScene.ts | 2 +- .../api/ResponseTransformers.test.ts | 216 ++++++++++++++++++ .../dashboard/api/ResponseTransformers.ts | 179 +++++++++++++++ .../dashboard/api/dashboard_api.test.ts | 75 ++++++ .../features/dashboard/api/dashboard_api.ts | 181 ++++----------- .../app/features/dashboard/api/legacy.test.ts | 55 +++++ public/app/features/dashboard/api/legacy.ts | 40 ++++ public/app/features/dashboard/api/types.ts | 31 +++ .../app/features/dashboard/api/utils.test.ts | 51 +++++ public/app/features/dashboard/api/utils.ts | 47 ++++ public/app/features/dashboard/api/v0.test.ts | 65 ++++++ public/app/features/dashboard/api/v0.ts | 109 +++++++++ public/app/features/dashboard/api/v2.test.ts | 69 ++++++ public/app/features/dashboard/api/v2.ts | 68 ++++++ .../utils/validation.test.ts | 64 ++++++ public/app/types/dashboard.ts | 1 + 30 files changed, 1443 insertions(+), 155 deletions(-) create mode 100644 public/app/core/components/Select/DashboardPicker.test.tsx create mode 100644 public/app/features/dashboard/api/ResponseTransformers.test.ts create mode 100644 public/app/features/dashboard/api/ResponseTransformers.ts create mode 100644 public/app/features/dashboard/api/dashboard_api.test.ts create mode 100644 public/app/features/dashboard/api/legacy.test.ts create mode 100644 public/app/features/dashboard/api/legacy.ts create mode 100644 public/app/features/dashboard/api/types.ts create mode 100644 public/app/features/dashboard/api/utils.test.ts create mode 100644 public/app/features/dashboard/api/utils.ts create mode 100644 public/app/features/dashboard/api/v0.test.ts create mode 100644 public/app/features/dashboard/api/v0.ts create mode 100644 public/app/features/dashboard/api/v2.test.ts create mode 100644 public/app/features/dashboard/api/v2.ts create mode 100644 public/app/features/manage-dashboards/utils/validation.test.ts diff --git a/.betterer.results b/.betterer.results index 45b148869ea..2251c15fce8 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3433,7 +3433,12 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"] ], - "public/app/features/dashboard/api/dashboard_api.ts:5381": [ + "public/app/features/dashboard/api/ResponseTransformers.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"] + ], + "public/app/features/dashboard/api/v0.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/dashboard/components/AddLibraryPanelWidget/AddLibraryPanelWidget.tsx:5381": [ 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 56aa8580034..03bd5d6a58c 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -227,6 +227,7 @@ Experimental features might be changed or removed without prior notice. | `crashDetection` | Enables browser crash detection reporting to Faro. | | `jaegerBackendMigration` | Enables querying the Jaeger data source without the proxy | | `alertingNotificationsStepMode` | Enables simplified step mode in the notifications section | +| `useV2DashboardsAPI` | Use the v2 kubernetes API in the frontend for dashboards | | `unifiedHistory` | Displays the navigation history so the user can navigate back to previous pages | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 628c520d832..9bb2f0f3cbe 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -240,6 +240,7 @@ export interface FeatureToggles { alertingUIOptimizeReducer?: boolean; azureMonitorEnableUserAuth?: boolean; alertingNotificationsStepMode?: boolean; + useV2DashboardsAPI?: boolean; feedbackButton?: boolean; elasticsearchCrossClusterSearch?: boolean; unifiedHistory?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index f2a69eef311..370497314d5 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1662,6 +1662,13 @@ var ( Owner: grafanaAlertingSquad, FrontendOnly: true, }, + { + Name: "useV2DashboardsAPI", + Description: "Use the v2 kubernetes API in the frontend for dashboards", + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + RequiresRestart: true, // changes the API routing + }, { Name: "feedbackButton", Description: "Enables a button to send feedback from the Grafana UI", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 10d467b4d99..f4a415c1f0f 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -221,6 +221,7 @@ reportingUseRawTimeRange,preview,@grafana/sharing-squad,false,false,false alertingUIOptimizeReducer,GA,@grafana/alerting-squad,false,false,true azureMonitorEnableUserAuth,GA,@grafana/partner-datasources,false,false,false alertingNotificationsStepMode,experimental,@grafana/alerting-squad,false,false,true +useV2DashboardsAPI,experimental,@grafana/dashboards-squad,false,true,false feedbackButton,experimental,@grafana/grafana-operator-experience-squad,false,false,false elasticsearchCrossClusterSearch,preview,@grafana/aws-datasources,false,false,false unifiedHistory,experimental,@grafana/grafana-frontend-platform,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index a90b3ac5cb7..ef2def13559 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -895,6 +895,10 @@ const ( // Enables simplified step mode in the notifications section FlagAlertingNotificationsStepMode = "alertingNotificationsStepMode" + // FlagUseV2DashboardsAPI + // Use the v2 kubernetes API in the frontend for dashboards + FlagUseV2DashboardsAPI = "useV2DashboardsAPI" + // FlagFeedbackButton // Enables a button to send feedback from the Grafana UI FlagFeedbackButton = "feedbackButton" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 9349be4cbbf..6241d3733f8 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3620,6 +3620,19 @@ "expression": "true" } }, + { + "metadata": { + "name": "useV2DashboardsAPI", + "resourceVersion": "1732535420861", + "creationTimestamp": "2024-11-25T11:50:20Z" + }, + "spec": { + "description": "Use the v2 kubernetes API in the frontend for dashboards", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "requiresRestart": true + } + }, { "metadata": { "name": "userStorageAPI", diff --git a/public/app/core/components/Select/DashboardPicker.test.tsx b/public/app/core/components/Select/DashboardPicker.test.tsx new file mode 100644 index 00000000000..7e97f86c3d8 --- /dev/null +++ b/public/app/core/components/Select/DashboardPicker.test.tsx @@ -0,0 +1,135 @@ +import { noop } from 'lodash'; +import { Props } from 'react-virtualized-auto-sizer'; +import { render, screen, userEvent, waitFor } from 'test/test-utils'; + +import { config } from '@grafana/runtime'; +import { defaultDashboard as defaultDashboardData } from '@grafana/schema'; +import { + DashboardV2Spec, + defaultDashboardV2Spec, +} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; +import { DashboardSearchItemType } from 'app/features/search/types'; +import { DashboardDTO } from 'app/types'; + +import { DashboardPicker } from './DashboardPicker'; + +jest.mock('app/core/services/backend_srv', () => ({ + ...jest.requireActual('app/core/services/backend_srv'), + backendSrv: { + ...jest.requireActual('app/core/services/backend_srv').backendSrv, + search: jest.fn(), + }, +})); + +const getDashboardDTO = jest.fn(); + +jest.mock('app/features/dashboard/api/dashboard_api', () => ({ + getDashboardAPI: () => ({ + getDashboardDTO: getDashboardDTO, + }), +})); + +jest.mock('react-virtualized-auto-sizer', () => { + return ({ children }: Props) => + children({ + height: 600, + scaledHeight: 600, + scaledWidth: 1, + width: 1, + }); +}); + +jest.mocked(backendSrv.search).mockResolvedValue([ + { + uid: 'dash-1', + type: DashboardSearchItemType.DashDB, + title: 'Dashboard 1', + uri: '', + url: '', + tags: [], + isStarred: false, + }, + { + uid: 'dash-2', + type: DashboardSearchItemType.DashDB, + title: 'Dashboard 2', + uri: '', + url: '', + tags: [], + isStarred: false, + }, + { + uid: 'dash-3', + type: DashboardSearchItemType.DashDB, + title: 'Dashboard 3', + uri: '', + url: '', + tags: [], + isStarred: false, + }, +]); + +const mockDashboard: DashboardDTO = { + dashboard: { + ...defaultDashboardData, + uid: 'dash-2', + title: 'Dashboard 2', + }, + meta: {}, +}; + +const mockDashboardV2: DashboardWithAccessInfo = { + apiVersion: 'v2alpha0', + kind: 'DashboardWithAccessInfo', + spec: { + ...defaultDashboardV2Spec(), + title: 'Dashboard 2', + }, + metadata: { + name: 'dash-2', + resourceVersion: '0', + creationTimestamp: '0', + annotations: {}, + }, + access: { + canEdit: true, + canSave: true, + canStar: true, + canShare: true, + }, +}; + +describe('DashboardPicker', () => { + describe.each([ + ['v1', mockDashboard], + ['v2', mockDashboardV2], + ])('Dashboard %s', (format, dashboard) => { + beforeEach(() => { + config.featureToggles.useV2DashboardsAPI = format === 'v2'; + getDashboardDTO.mockResolvedValue(dashboard); + }); + + it('should fetch and display dashboards', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Dashboards/Dashboard 2')).toBeInTheDocument(); + expect(getDashboardDTO).toHaveBeenCalledWith('dash-2', undefined); + }); + }); + + it('should search for dashboards', async () => { + render(); + + await userEvent.type(screen.getByRole('combobox'), 'Dashboard 2'); + + await waitFor(() => { + expect(screen.getByText('Dashboards/Dashboard 2')).toBeInTheDocument(); + }); + + expect(backendSrv.search).toHaveBeenCalledWith({ type: 'dash-db', query: 'Dashboard 2', limit: 100 }); + }); + }); +}); diff --git a/public/app/core/components/Select/DashboardPicker.tsx b/public/app/core/components/Select/DashboardPicker.tsx index bbce99e64c2..530b98f4f61 100644 --- a/public/app/core/components/Select/DashboardPicker.tsx +++ b/public/app/core/components/Select/DashboardPicker.tsx @@ -2,8 +2,10 @@ import debounce from 'debounce-promise'; import { useCallback, useEffect, useState } from 'react'; import { SelectableValue } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { AsyncSelectProps, AsyncSelect } from '@grafana/ui'; import { backendSrv } from 'app/core/services/backend_srv'; +import { AnnoKeyFolder, AnnoKeyFolderTitle } from 'app/features/apiserver/types'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; import { DashboardSearchItem } from 'app/features/search/types'; import { DashboardDTO } from 'app/types'; @@ -55,17 +57,36 @@ export const DashboardPicker = ({ (async () => { // value was manually changed from outside or we are rendering for the first time. // We need to fetch dashboard information. - const res = await getDashboardAPI().getDashboardDTO(value); - if (res.dashboard) { + const isUIReadyForV2 = config.featureToggles.useV2DashboardsAPI; + if (isUIReadyForV2) { + // When using getDashboardAPI, if isUIReadyForV2 is true, we will pass `v2` prop + // That will return a dashboard response using schema v2. We only ask for `v2` when the component is ready to process the new shape + const resWithSchemaV2 = await getDashboardAPI('v2').getDashboardDTO(value, undefined); + setCurrent({ value: { - uid: res.dashboard.uid, - title: res.dashboard.title, - folderTitle: res.meta.folderTitle, - folderUid: res.meta.folderUid, + uid: resWithSchemaV2.metadata.name, + title: resWithSchemaV2.spec.title, + folderTitle: resWithSchemaV2.metadata.annotations?.[AnnoKeyFolderTitle], + folderUid: resWithSchemaV2.metadata.annotations?.[AnnoKeyFolder], }, - label: formatLabel(res.meta?.folderTitle, res.dashboard.title), + label: formatLabel(resWithSchemaV2.metadata.annotations?.[AnnoKeyFolder], resWithSchemaV2.spec.title), }); + } else { + // when using getDashboardAPI, if isUIReadyForV2 is false, we will always return the v1 schema version + const resWithSchemaV1 = await getDashboardAPI().getDashboardDTO(value, undefined); + + if (resWithSchemaV1.dashboard) { + setCurrent({ + value: { + uid: resWithSchemaV1.dashboard.uid, + title: resWithSchemaV1.dashboard.title, + folderTitle: resWithSchemaV1.meta.folderTitle, + folderUid: resWithSchemaV1.meta.folderUid, + }, + label: formatLabel(resWithSchemaV1.meta?.folderTitle, resWithSchemaV1.dashboard.title), + }); + } } })(); // we don't need to rerun this effect every time `current` changes diff --git a/public/app/features/apiserver/client.ts b/public/app/features/apiserver/client.ts index 78d4c396d2f..76f0383665c 100644 --- a/public/app/features/apiserver/client.ts +++ b/public/app/features/apiserver/client.ts @@ -12,6 +12,7 @@ import { ResourceClient, ObjectMeta, K8sAPIGroupList, + AnnoKeySavedFromUI, } from './types'; export interface GroupVersionResource { @@ -104,7 +105,7 @@ function setSavedFromUIAnnotation(meta: Partial) { if (!meta.annotations) { meta.annotations = {}; } - meta.annotations['grafana.app/saved-from-ui'] = config.buildInfo.versionString; + meta.annotations[AnnoKeySavedFromUI] = config.buildInfo.versionString; } export class DatasourceAPIVersions { diff --git a/public/app/features/apiserver/types.ts b/public/app/features/apiserver/types.ts index 72f00b9c174..8c8395b070b 100644 --- a/public/app/features/apiserver/types.ts +++ b/public/app/features/apiserver/types.ts @@ -24,7 +24,7 @@ export interface ObjectMeta { // The first time this was saved creationTimestamp: string; // General resource annotations -- including the common grafana.app values - annotations?: GrafanaAnnotations; + annotations?: GrafanaAnnotations & GrafanaClientAnnotations; // General application level key+value pairs labels?: Record; } @@ -33,6 +33,9 @@ export const AnnoKeyCreatedBy = 'grafana.app/createdBy'; export const AnnoKeyUpdatedTimestamp = 'grafana.app/updatedTimestamp'; export const AnnoKeyUpdatedBy = 'grafana.app/updatedBy'; export const AnnoKeyFolder = 'grafana.app/folder'; +export const AnnoKeyFolderTitle = 'grafana.app/folderTitle'; +export const AnnoKeyFolderId = 'grafana.app/folderId'; +export const AnnoKeyFolderUrl = 'grafana.app/folderUrl'; export const AnnoKeyMessage = 'grafana.app/message'; export const AnnoKeySlug = 'grafana.app/slug'; @@ -40,8 +43,11 @@ export const AnnoKeySlug = 'grafana.app/slug'; export const AnnoKeyRepoName = 'grafana.app/repoName'; export const AnnoKeyRepoPath = 'grafana.app/repoPath'; export const AnnoKeyRepoHash = 'grafana.app/repoHash'; -const AnnoKeyRepoTimestamp = 'grafana.app/repoTimestamp'; +export const AnnoKeyRepoTimestamp = 'grafana.app/repoTimestamp'; +export const AnnoKeySavedFromUI = 'grafana.app/saved-from-ui'; + +// Annotations provided by the API type GrafanaAnnotations = { [AnnoKeyCreatedBy]?: string; [AnnoKeyUpdatedTimestamp]?: string; @@ -53,9 +59,16 @@ type GrafanaAnnotations = { [AnnoKeyRepoPath]?: string; [AnnoKeyRepoHash]?: string; [AnnoKeyRepoTimestamp]?: string; +}; - // Any key value - [key: string]: string | undefined; +// Annotations provided by the front-end client +type GrafanaClientAnnotations = { + [AnnoKeyMessage]?: string; + [AnnoKeyFolderTitle]?: string; + [AnnoKeyFolderUrl]?: string; + [AnnoKeyFolderId]?: number; + [AnnoKeyFolderId]?: number; + [AnnoKeySavedFromUI]?: string; }; export interface Resource extends TypeMeta { diff --git a/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.test.ts b/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.test.ts index bfe7a4b658d..f425f2a7a5a 100644 --- a/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.test.ts +++ b/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.test.ts @@ -1,7 +1,7 @@ import { DataSourceApi, PluginType, VariableSupportType } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { buildNewDashboardSaveModel } from './buildNewDashboardSaveModel'; +import { buildNewDashboardSaveModel, buildNewDashboardSaveModelV2 } from './buildNewDashboardSaveModel'; const fakeDsMock: DataSourceApi = { name: 'fake-std', @@ -58,7 +58,7 @@ jest.mock('@grafana/runtime', () => ({ }), })); -describe('buildNewDashboardSaveModel', () => { +describe('buildNewDashboardSaveModelV1', () => { it('should not have template variables defined by default', async () => { const result = await buildNewDashboardSaveModel(); expect(result.dashboard.templating).toBeUndefined(); @@ -85,3 +85,31 @@ describe('buildNewDashboardSaveModel', () => { }); }); }); + +describe('buildNewDashboardSaveModelV2', () => { + it('should not have template variables defined by default', async () => { + const result = await buildNewDashboardSaveModelV2(); + expect(result.spec.variables).toEqual([]); + }); + + describe('when featureToggles.newDashboardWithFiltersAndGroupBy is true', () => { + beforeAll(() => { + config.featureToggles.newDashboardWithFiltersAndGroupBy = true; + }); + afterAll(() => { + config.featureToggles.newDashboardWithFiltersAndGroupBy = false; + }); + + it('should add filter and group by variables if the datasource supports it and is set as default', async () => { + const result = await buildNewDashboardSaveModelV2(); + expect(result.spec.variables).toHaveLength(2); + expect(result.spec.variables[0].kind).toBe('AdhocVariable'); + expect(result.spec.variables[1].kind).toBe('GroupByVariable'); + }); + + it("should set the new dashboard's timezone to the user's timezone", async () => { + const result = await buildNewDashboardSaveModelV2(); + expect(result.spec.timeSettings.timezone).toEqual('Africa/Abidjan'); + }); + }); +}); diff --git a/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts b/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts index 62bd1985f6d..b61d89ec7d4 100644 --- a/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts @@ -1,5 +1,16 @@ import { config } from '@grafana/runtime'; import { VariableModel, defaultDashboard } from '@grafana/schema'; +import { + AdhocVariableKind, + DashboardV2Spec, + defaultAdhocVariableSpec, + defaultDashboardV2Spec, + defaultGroupByVariableSpec, + defaultTimeSettingsSpec, + GroupByVariableKind, +} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { AnnoKeyFolder } from 'app/features/apiserver/types'; +import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { DashboardDTO } from 'app/types'; @@ -62,3 +73,75 @@ export async function buildNewDashboardSaveModel(urlFolderUid?: string): Promise return data; } + +export async function buildNewDashboardSaveModelV2( + urlFolderUid?: string +): Promise> { + let variablesList = defaultDashboardV2Spec().variables; + + if (config.featureToggles.newDashboardWithFiltersAndGroupBy) { + // Add filter and group by variables if the datasource supports it + const defaultDs = await getDatasourceSrv().get(); + + if (defaultDs.getTagKeys) { + const datasourceRef = { + type: defaultDs.meta.id, + uid: defaultDs.uid, + }; + + const filterVariable: AdhocVariableKind = { + kind: 'AdhocVariable', + spec: { ...defaultAdhocVariableSpec(), name: 'Filter', datasource: datasourceRef }, + }; + + const groupByVariable: GroupByVariableKind = { + kind: 'GroupByVariable', + spec: { + ...defaultGroupByVariableSpec(), + datasource: datasourceRef, + name: 'Group by', + }, + }; + + variablesList = (variablesList || []).concat([filterVariable, groupByVariable]); + } + } + + const data: DashboardWithAccessInfo = { + apiVersion: 'v2alpha0', + kind: 'DashboardWithAccessInfo', + spec: { + ...defaultDashboardV2Spec(), + title: 'New dashboard', + timeSettings: { + ...defaultTimeSettingsSpec(), + timezone: config.bootData.user?.timezone || defaultTimeSettingsSpec().timezone, + }, + }, + access: { + canStar: false, + canShare: false, + canDelete: false, + // Not sure this should belong here or to metadata.annotations + isNew: true, + }, + metadata: { + name: '', + resourceVersion: '0', + creationTimestamp: '0', + annotations: { + [AnnoKeyFolder]: '', + }, + }, + }; + + if (variablesList) { + data.spec.variables = variablesList; + } + + if (urlFolderUid && data.metadata.annotations) { + data.metadata.annotations[AnnoKeyFolder] = urlFolderUid; + } + + return data; +} diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts index 12791e79736..3b340246c4d 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts @@ -26,7 +26,7 @@ import { TextVariableKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/examples'; -import { DashboardWithAccessInfo } from 'app/features/dashboard/api/dashboard_api'; +import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index ff3f2896f62..5eaae2ef633 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -51,7 +51,7 @@ import { QueryVariableKind, TextVariableKind, } from '@grafana/schema/src/schema/dashboard/v2alpha0/dashboard.gen'; -import { DashboardWithAccessInfo } from 'app/features/dashboard/api/dashboard_api'; +import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior'; diff --git a/public/app/features/dashboard/api/ResponseTransformers.test.ts b/public/app/features/dashboard/api/ResponseTransformers.test.ts new file mode 100644 index 00000000000..a614f0b4a10 --- /dev/null +++ b/public/app/features/dashboard/api/ResponseTransformers.test.ts @@ -0,0 +1,216 @@ +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { DashboardDTO } from 'app/types'; + +import { ResponseTransformers } from './ResponseTransformers'; +import { DashboardWithAccessInfo } from './types'; + +describe('ResponseTransformers', () => { + describe('v1 transformation', () => { + it('should transform DashboardDTO to DashboardWithAccessInfo', () => { + const dashboardDTO: DashboardDTO = { + meta: { + created: '2023-01-01T00:00:00Z', + createdBy: 'user1', + updated: '2023-01-02T00:00:00Z', + updatedBy: 'user2', + folderUid: 'folder1', + slug: 'dashboard-slug', + url: '/d/dashboard-slug', + canAdmin: true, + canDelete: true, + canEdit: true, + canSave: true, + canShare: true, + canStar: true, + annotationsPermissions: { + dashboard: { canAdd: true, canEdit: true, canDelete: true }, + organization: { canAdd: true, canEdit: true, canDelete: true }, + }, + }, + dashboard: { + uid: 'dashboard1', + title: 'Dashboard Title', + description: 'Dashboard Description', + tags: ['tag1', 'tag2'], + schemaVersion: 1, + graphTooltip: 0, + preload: true, + liveNow: false, + editable: true, + time: { from: 'now-6h', to: 'now' }, + timezone: 'browser', + refresh: '5m', + timepicker: { + refresh_intervals: ['5s', '10s', '30s'], + hidden: false, + time_options: ['5m', '15m', '1h'], + nowDelay: '1m', + }, + fiscalYearStartMonth: 1, + weekStart: 'monday', + version: 1, + links: [], + annotations: { + list: [], + }, + }, + }; + + const transformed = ResponseTransformers.ensureV2Response(dashboardDTO); + + expect(transformed.apiVersion).toBe('v2alpha1'); + expect(transformed.kind).toBe('DashboardWithAccessInfo'); + expect(transformed.metadata.creationTimestamp).toBe(dashboardDTO.meta.created); + expect(transformed.metadata.name).toBe(dashboardDTO.dashboard.uid); + expect(transformed.metadata.resourceVersion).toBe(dashboardDTO.dashboard.version?.toString()); + expect(transformed.metadata.annotations?.['grafana.app/createdBy']).toBe(dashboardDTO.meta.createdBy); + expect(transformed.metadata.annotations?.['grafana.app/updatedBy']).toBe(dashboardDTO.meta.updatedBy); + expect(transformed.metadata.annotations?.['grafana.app/updatedTimestamp']).toBe(dashboardDTO.meta.updated); + expect(transformed.metadata.annotations?.['grafana.app/folder']).toBe(dashboardDTO.meta.folderUid); + expect(transformed.metadata.annotations?.['grafana.app/slug']).toBe(dashboardDTO.meta.slug); + + const spec = transformed.spec; + expect(spec.title).toBe(dashboardDTO.dashboard.title); + expect(spec.description).toBe(dashboardDTO.dashboard.description); + expect(spec.tags).toEqual(dashboardDTO.dashboard.tags); + expect(spec.schemaVersion).toBe(dashboardDTO.dashboard.schemaVersion); + expect(spec.cursorSync).toBe('Off'); // Assuming transformCursorSynctoEnum(0) returns 'Off' + expect(spec.preload).toBe(dashboardDTO.dashboard.preload); + expect(spec.liveNow).toBe(dashboardDTO.dashboard.liveNow); + expect(spec.editable).toBe(dashboardDTO.dashboard.editable); + expect(spec.timeSettings.from).toBe(dashboardDTO.dashboard.time?.from); + expect(spec.timeSettings.to).toBe(dashboardDTO.dashboard.time?.to); + expect(spec.timeSettings.timezone).toBe(dashboardDTO.dashboard.timezone); + expect(spec.timeSettings.autoRefresh).toBe(dashboardDTO.dashboard.refresh); + expect(spec.timeSettings.autoRefreshIntervals).toEqual(dashboardDTO.dashboard.timepicker?.refresh_intervals); + expect(spec.timeSettings.hideTimepicker).toBe(dashboardDTO.dashboard.timepicker?.hidden); + expect(spec.timeSettings.quickRanges).toEqual(dashboardDTO.dashboard.timepicker?.time_options); + expect(spec.timeSettings.nowDelay).toBe(dashboardDTO.dashboard.timepicker?.nowDelay); + expect(spec.timeSettings.fiscalYearStartMonth).toBe(dashboardDTO.dashboard.fiscalYearStartMonth); + expect(spec.timeSettings.weekStart).toBe(dashboardDTO.dashboard.weekStart); + expect(spec.links).toEqual([]); // Assuming transformDashboardLinksToEnums([]) returns [] + expect(spec.annotations).toEqual([]); + }); + }); + + describe('v2 transformation', () => { + it('should return the same object if it is already a DashboardDTO', () => { + const dashboard: DashboardDTO = { + dashboard: { + schemaVersion: 1, + title: 'Dashboard Title', + uid: 'dashboard1', + version: 1, + }, + meta: {}, + }; + + expect(ResponseTransformers.ensureV1Response(dashboard)).toBe(dashboard); + }); + + it('should transform DashboardWithAccessInfo to DashboardDTO', () => { + const dashboardV2: DashboardWithAccessInfo = { + apiVersion: 'v2alpha1', + kind: 'DashboardWithAccessInfo', + metadata: { + creationTimestamp: '2023-01-01T00:00:00Z', + name: 'dashboard1', + resourceVersion: '1', + annotations: { + 'grafana.app/createdBy': 'user1', + 'grafana.app/updatedBy': 'user2', + 'grafana.app/updatedTimestamp': '2023-01-02T00:00:00Z', + 'grafana.app/folder': 'folder1', + 'grafana.app/slug': 'dashboard-slug', + }, + }, + spec: { + title: 'Dashboard Title', + description: 'Dashboard Description', + tags: ['tag1', 'tag2'], + schemaVersion: 1, + cursorSync: 'Off', + preload: true, + liveNow: false, + editable: true, + timeSettings: { + from: 'now-6h', + to: 'now', + timezone: 'browser', + autoRefresh: '5m', + autoRefreshIntervals: ['5s', '10s', '30s'], + hideTimepicker: false, + quickRanges: ['5m', '15m', '1h'], + nowDelay: '1m', + fiscalYearStartMonth: 1, + weekStart: 'monday', + }, + links: [], + annotations: [], + variables: [], + elements: {}, + layout: { + kind: 'GridLayout', + spec: { + items: [], + }, + }, + }, + access: { + url: '/d/dashboard-slug', + canAdmin: true, + canDelete: true, + canEdit: true, + canSave: true, + canShare: true, + canStar: true, + slug: 'dashboard-slug', + annotationsPermissions: { + dashboard: { canAdd: true, canEdit: true, canDelete: true }, + organization: { canAdd: true, canEdit: true, canDelete: true }, + }, + }, + }; + + const transformed = ResponseTransformers.ensureV1Response(dashboardV2); + + expect(transformed.meta.created).toBe(dashboardV2.metadata.creationTimestamp); + expect(transformed.meta.createdBy).toBe(dashboardV2.metadata.annotations?.['grafana.app/createdBy']); + expect(transformed.meta.updated).toBe(dashboardV2.metadata.annotations?.['grafana.app/updatedTimestamp']); + expect(transformed.meta.updatedBy).toBe(dashboardV2.metadata.annotations?.['grafana.app/updatedBy']); + expect(transformed.meta.folderUid).toBe(dashboardV2.metadata.annotations?.['grafana.app/folder']); + expect(transformed.meta.slug).toBe(dashboardV2.metadata.annotations?.['grafana.app/slug']); + expect(transformed.meta.url).toBe(dashboardV2.access.url); + expect(transformed.meta.canAdmin).toBe(dashboardV2.access.canAdmin); + expect(transformed.meta.canDelete).toBe(dashboardV2.access.canDelete); + expect(transformed.meta.canEdit).toBe(dashboardV2.access.canEdit); + expect(transformed.meta.canSave).toBe(dashboardV2.access.canSave); + expect(transformed.meta.canShare).toBe(dashboardV2.access.canShare); + expect(transformed.meta.canStar).toBe(dashboardV2.access.canStar); + expect(transformed.meta.annotationsPermissions).toEqual(dashboardV2.access.annotationsPermissions); + + const dashboard = transformed.dashboard; + expect(dashboard.uid).toBe(dashboardV2.metadata.name); + expect(dashboard.title).toBe(dashboardV2.spec.title); + expect(dashboard.description).toBe(dashboardV2.spec.description); + expect(dashboard.tags).toEqual(dashboardV2.spec.tags); + expect(dashboard.schemaVersion).toBe(dashboardV2.spec.schemaVersion); + // expect(dashboard.graphTooltip).toBe(0); // Assuming transformCursorSynctoEnum('Off') returns 0 + expect(dashboard.preload).toBe(dashboardV2.spec.preload); + expect(dashboard.liveNow).toBe(dashboardV2.spec.liveNow); + expect(dashboard.editable).toBe(dashboardV2.spec.editable); + expect(dashboard.time?.from).toBe(dashboardV2.spec.timeSettings.from); + expect(dashboard.time?.to).toBe(dashboardV2.spec.timeSettings.to); + expect(dashboard.timezone).toBe(dashboardV2.spec.timeSettings.timezone); + expect(dashboard.refresh).toBe(dashboardV2.spec.timeSettings.autoRefresh); + expect(dashboard.timepicker?.refresh_intervals).toEqual(dashboardV2.spec.timeSettings.autoRefreshIntervals); + expect(dashboard.timepicker?.hidden).toBe(dashboardV2.spec.timeSettings.hideTimepicker); + expect(dashboard.timepicker?.time_options).toEqual(dashboardV2.spec.timeSettings.quickRanges); + expect(dashboard.timepicker?.nowDelay).toBe(dashboardV2.spec.timeSettings.nowDelay); + expect(dashboard.fiscalYearStartMonth).toBe(dashboardV2.spec.timeSettings.fiscalYearStartMonth); + expect(dashboard.weekStart).toBe(dashboardV2.spec.timeSettings.weekStart); + expect(dashboard.links).toEqual([]); // Assuming transformDashboardLinksToEnums([]) returns [] + expect(dashboard.annotations).toEqual({ list: [] }); + }); + }); +}); diff --git a/public/app/features/dashboard/api/ResponseTransformers.ts b/public/app/features/dashboard/api/ResponseTransformers.ts new file mode 100644 index 00000000000..98b7ac12cdc --- /dev/null +++ b/public/app/features/dashboard/api/ResponseTransformers.ts @@ -0,0 +1,179 @@ +import { + DashboardV2Spec, + defaultDashboardV2Spec, + defaultTimeSettingsSpec, +} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { transformCursorSynctoEnum } from 'app/features/dashboard-scene/serialization/transformToV2TypesUtils'; +import { DashboardDataDTO, DashboardDTO } from 'app/types'; + +import { DashboardWithAccessInfo } from './types'; +import { isDashboardResource, isDashboardV0Spec, isDashboardV2Spec } from './utils'; + +export function ensureV2Response( + dto: DashboardDTO | DashboardWithAccessInfo | DashboardWithAccessInfo +): DashboardWithAccessInfo { + if (isDashboardResource(dto) && isDashboardV2Spec(dto.spec)) { + return dto as DashboardWithAccessInfo; + } + + // after discarding the dto is not a v2 spec, we can safely assume it's a v0 spec or a dashboardDTO + dto = dto as unknown as DashboardWithAccessInfo | DashboardDTO; + + const timeSettingsDefaults = defaultTimeSettingsSpec(); + const dashboardDefaults = defaultDashboardV2Spec(); + + const dashboard = isDashboardResource(dto) ? dto.spec : dto.dashboard; + + const accessAndMeta = isDashboardResource(dto) + ? { + ...dto.access, + created: dto.metadata.creationTimestamp, + createdBy: dto.metadata.annotations?.['grafana.app/createdBy'], + updatedBy: dto.metadata.annotations?.['grafana.app/updatedBy'], + updated: dto.metadata.annotations?.['grafana.app/updatedTimestamp'], + folderUid: dto.metadata.annotations?.['grafana.app/folder'], + slug: dto.metadata.annotations?.['grafana.app/slug'], + } + : dto.meta; + + const spec: DashboardV2Spec = { + title: dashboard.title, + description: dashboard.description, + tags: dashboard.tags, + schemaVersion: dashboard.schemaVersion, + cursorSync: transformCursorSynctoEnum(dashboard.graphTooltip), + preload: dashboard.preload || dashboardDefaults.preload, + liveNow: dashboard.liveNow, + editable: dashboard.editable, + timeSettings: { + from: dashboard.time?.from || timeSettingsDefaults.from, + to: dashboard.time?.to || timeSettingsDefaults.to, + timezone: dashboard.timezone || timeSettingsDefaults.timezone, + autoRefresh: dashboard.refresh || timeSettingsDefaults.autoRefresh, + autoRefreshIntervals: dashboard.timepicker?.refresh_intervals || timeSettingsDefaults.autoRefreshIntervals, + fiscalYearStartMonth: dashboard.fiscalYearStartMonth || timeSettingsDefaults.fiscalYearStartMonth, + hideTimepicker: dashboard.timepicker?.hidden || timeSettingsDefaults.hideTimepicker, + quickRanges: dashboard.timepicker?.time_options || timeSettingsDefaults.quickRanges, + weekStart: dashboard.weekStart || timeSettingsDefaults.weekStart, + nowDelay: dashboard.timepicker?.nowDelay || timeSettingsDefaults.nowDelay, + }, + links: dashboard.links || [], + annotations: [], // TODO + variables: [], // todo + elements: {}, // todo + layout: { + // todo + kind: 'GridLayout', + spec: { + items: [], + }, + }, + }; + + return { + apiVersion: 'v2alpha1', + kind: 'DashboardWithAccessInfo', + metadata: { + creationTimestamp: accessAndMeta.created || '', // TODO verify this empty string is valid + name: dashboard.uid, + resourceVersion: dashboard.version?.toString() || '0', + annotations: { + 'grafana.app/createdBy': accessAndMeta.createdBy, + 'grafana.app/updatedBy': accessAndMeta.updatedBy, + 'grafana.app/updatedTimestamp': accessAndMeta.updated, + 'grafana.app/folder': accessAndMeta.folderUid, + 'grafana.app/slug': accessAndMeta.slug, + }, + }, + spec, + access: { + url: accessAndMeta.url || '', + canAdmin: accessAndMeta.canAdmin, + canDelete: accessAndMeta.canDelete, + canEdit: accessAndMeta.canEdit, + canSave: accessAndMeta.canSave, + canShare: accessAndMeta.canShare, + canStar: accessAndMeta.canStar, + slug: accessAndMeta.slug, + annotationsPermissions: accessAndMeta.annotationsPermissions, + }, + }; +} + +export function ensureV1Response( + dashboard: DashboardDTO | DashboardWithAccessInfo | DashboardWithAccessInfo +): DashboardDTO { + // if dashboard is not on v0 schema or v2 schema, return as is + if (!isDashboardResource(dashboard)) { + return dashboard; + } + + const spec = dashboard.spec; + // if dashboard is on v0 schema + if (isDashboardV0Spec(spec)) { + return { + meta: { + ...dashboard.access, + isNew: false, + isFolder: false, + uid: dashboard.metadata.name, + k8s: dashboard.metadata, + }, + dashboard: spec, + }; + } else { + // if dashboard is on v2 schema convert to v1 schema + return { + meta: { + created: dashboard.metadata.creationTimestamp, + createdBy: dashboard.metadata.annotations?.['grafana.app/createdBy'] ?? '', + updated: dashboard.metadata.annotations?.['grafana.app/updatedTimestamp'], + updatedBy: dashboard.metadata.annotations?.['grafana.app/updatedBy'], + folderUid: dashboard.metadata.annotations?.['grafana.app/folder'], + slug: dashboard.metadata.annotations?.['grafana.app/slug'], + url: dashboard.access.url, + canAdmin: dashboard.access.canAdmin, + canDelete: dashboard.access.canDelete, + canEdit: dashboard.access.canEdit, + canSave: dashboard.access.canSave, + canShare: dashboard.access.canShare, + canStar: dashboard.access.canStar, + annotationsPermissions: dashboard.access.annotationsPermissions, + }, + dashboard: { + uid: dashboard.metadata.name, + title: spec.title, + description: spec.description, + tags: spec.tags, + schemaVersion: spec.schemaVersion, + // @ts-ignore TODO: Use transformers for these enums + // graphTooltip: spec.cursorSync, // Assuming transformCursorSynctoEnum is reversible + preload: spec.preload, + liveNow: spec.liveNow, + editable: spec.editable, + time: { + from: spec.timeSettings.from, + to: spec.timeSettings.to, + }, + timezone: spec.timeSettings.timezone, + refresh: spec.timeSettings.autoRefresh, + timepicker: { + refresh_intervals: spec.timeSettings.autoRefreshIntervals, + hidden: spec.timeSettings.hideTimepicker, + time_options: spec.timeSettings.quickRanges, + nowDelay: spec.timeSettings.nowDelay, + }, + fiscalYearStartMonth: spec.timeSettings.fiscalYearStartMonth, + weekStart: spec.timeSettings.weekStart, + version: parseInt(dashboard.metadata.resourceVersion, 10), + links: spec.links, // Assuming transformDashboardLinksToEnums is reversible + annotations: { list: [] }, // TODO + }, + }; + } +} + +export const ResponseTransformers = { + ensureV2Response, + ensureV1Response, +}; diff --git a/public/app/features/dashboard/api/dashboard_api.test.ts b/public/app/features/dashboard/api/dashboard_api.test.ts new file mode 100644 index 00000000000..9467d9d1385 --- /dev/null +++ b/public/app/features/dashboard/api/dashboard_api.test.ts @@ -0,0 +1,75 @@ +import { config } from '@grafana/runtime'; + +import { getDashboardAPI, setDashboardAPI } from './dashboard_api'; +import { LegacyDashboardAPI } from './legacy'; +import { K8sDashboardAPI } from './v0'; +import { K8sDashboardV2APIStub } from './v2'; + +describe('DashboardApi', () => { + it('should use legacy api by default', () => { + expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI); + }); + + it('should allow overriding clients in test environment', () => { + process.env.NODE_ENV = 'test'; + const mockClient = { legacy: new LegacyDashboardAPI() }; + setDashboardAPI(mockClient); + const api = getDashboardAPI(); + expect(api).toBe(mockClient.legacy); + setDashboardAPI(undefined); + }); + + describe('when scenes enabled', () => { + beforeEach(() => { + config.featureToggles.dashboardScene = true; + }); + + it('should use legacy api kubernetesDashboards toggle is disabled', () => { + config.featureToggles.kubernetesDashboards = false; + expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI); + }); + + it('should use v0 api when and kubernetesDashboards toggle is enabled', () => { + config.featureToggles.kubernetesDashboards = true; + expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI); + }); + + it('should use v2 api when and useV2DashboardsAPI toggle is enabled', () => { + config.featureToggles.useV2DashboardsAPI = true; + expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardV2APIStub); + }); + }); + + describe('when scenes disabled', () => { + beforeEach(() => { + config.featureToggles.dashboardScene = false; + }); + + it('should use legacy api when kubernetesDashboards toggle is disabled', () => { + config.featureToggles.kubernetesDashboards = undefined; + expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI); + }); + + it('should use legacy api when kubernetesDashboards toggle is disabled', () => { + config.featureToggles.kubernetesDashboards = false; + expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI); + }); + + it('should use v0 api when kubernetesDashboards toggle is enabled', () => { + config.featureToggles.kubernetesDashboards = true; + expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI); + }); + + it('should use v0 api when kubernetesDashboards and useV2DashboardsAPI toggle is enabled', () => { + config.featureToggles.useV2DashboardsAPI = true; + config.featureToggles.kubernetesDashboards = true; + expect(getDashboardAPI()).toBeInstanceOf(K8sDashboardAPI); + }); + + it('should use legacy useV2DashboardsAPI toggle is enabled', () => { + config.featureToggles.useV2DashboardsAPI = true; + config.featureToggles.kubernetesDashboards = undefined; + expect(getDashboardAPI()).toBeInstanceOf(LegacyDashboardAPI); + }); + }); +}); diff --git a/public/app/features/dashboard/api/dashboard_api.ts b/public/app/features/dashboard/api/dashboard_api.ts index 22988facd18..0a5f45c137e 100644 --- a/public/app/features/dashboard/api/dashboard_api.ts +++ b/public/app/features/dashboard/api/dashboard_api.ts @@ -1,147 +1,52 @@ -import { UrlQueryMap } from '@grafana/data'; -import { config, getBackendSrv } from '@grafana/runtime'; -import { ScopedResourceClient } from 'app/features/apiserver/client'; -import { - AnnoKeyFolder, - AnnoKeyMessage, - Resource, - ResourceClient, - ResourceForCreate, -} from 'app/features/apiserver/types'; -import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types'; -import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; -import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; -import { DashboardDTO, DashboardDataDTO, SaveDashboardResponseDTO } from 'app/types'; +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { DashboardDTO } from 'app/types'; -export interface DashboardAPI { - /** Get a dashboard with the access control metadata */ - getDashboardDTO(uid: string, params?: UrlQueryMap): Promise; - /** Save dashboard */ - saveDashboard(options: SaveDashboardCommand): Promise; - /** Delete a dashboard */ - deleteDashboard(uid: string, showSuccessAlert: boolean): Promise; -} +import { LegacyDashboardAPI } from './legacy'; +import { DashboardAPI, DashboardWithAccessInfo } from './types'; +import { getDashboardsApiVersion } from './utils'; +import { K8sDashboardAPI } from './v0'; +import { K8sDashboardV2APIStub } from './v2'; -// Implemented using /api/dashboards/* -class LegacyDashboardAPI implements DashboardAPI { - constructor() {} +type DashboardAPIClients = { + legacy: DashboardAPI; + v0: DashboardAPI; + // v1: DashboardDTO; TODO[schema]: enable v1 when available + v2: DashboardAPI>; +}; - saveDashboard(options: SaveDashboardCommand): Promise { - dashboardWatcher.ignoreNextSave(); +type DashboardReturnTypes = DashboardDTO | DashboardWithAccessInfo; - return getBackendSrv().post('/api/dashboards/db/', { - dashboard: options.dashboard, - message: options.message ?? '', - overwrite: options.overwrite ?? false, - folderUid: options.folderUid, - }); - } +let clients: Partial | undefined; - deleteDashboard(uid: string, showSuccessAlert: boolean): Promise { - return getBackendSrv().delete(`/api/dashboards/uid/${uid}`, { showSuccessAlert }); - } - - getDashboardDTO(uid: string, params?: UrlQueryMap): Promise { - return getBackendSrv().get(`/api/dashboards/uid/${uid}`, params); - } -} - -export interface DashboardWithAccessInfo extends Resource { - access: Object; // TODO... -} - -// Implemented using /apis/dashboard.grafana.app/* -class K8sDashboardAPI implements DashboardAPI { - private client: ResourceClient; - - constructor() { - this.client = new ScopedResourceClient({ - group: 'dashboard.grafana.app', - version: 'v0alpha1', - resource: 'dashboards', - }); - } - - saveDashboard(options: SaveDashboardCommand): Promise { - const dashboard = options.dashboard as DashboardDataDTO; // type for the uid property - const obj: ResourceForCreate = { - metadata: { - ...options?.k8s, - }, - spec: { - ...dashboard, - }, - }; - - if (options.message) { - obj.metadata.annotations = { - ...obj.metadata.annotations, - [AnnoKeyMessage]: options.message, - }; - } else if (obj.metadata.annotations) { - delete obj.metadata.annotations[AnnoKeyMessage]; - } - - if (options.folderUid) { - obj.metadata.annotations = { - ...obj.metadata.annotations, - [AnnoKeyFolder]: options.folderUid, - }; - } - - if (dashboard.uid) { - obj.metadata.name = dashboard.uid; - return this.client.update(obj).then((v) => this.asSaveDashboardResponseDTO(v)); - } - return this.client.create(obj).then((v) => this.asSaveDashboardResponseDTO(v)); - } - - asSaveDashboardResponseDTO(v: Resource): SaveDashboardResponseDTO { - return { - uid: v.metadata.name, - version: v.spec.version ?? 0, - id: v.spec.id ?? 0, - status: 'success', - slug: '', - url: '', - }; - } - - deleteDashboard(uid: string, showSuccessAlert: boolean): Promise { - return this.client.delete(uid).then((v) => ({ - id: 0, - message: v.message, - title: 'deleted', - })); - } - - async getDashboardDTO(uid: string): Promise { - const dash = await this.client.subresource(uid, 'dto'); - return { - meta: { - ...dash.access, - isNew: false, - isFolder: false, - uid: dash.metadata.name, - k8s: dash.metadata, - }, - dashboard: dash.spec, - }; - } -} - -let instance: DashboardAPI | undefined = undefined; - -export function getDashboardAPI() { - if (!instance) { - instance = config.featureToggles.kubernetesDashboards ? new K8sDashboardAPI() : new LegacyDashboardAPI(); - } - return instance; -} - -export function setDashboardAPI(override: DashboardAPI | undefined) { +export function setDashboardAPI(override: Partial | undefined) { if (process.env.NODE_ENV !== 'test') { throw new Error('dashboardAPI can be only overridden in test environment'); } - instance = override; + clients = override; +} + +// Overloads +export function getDashboardAPI(): DashboardAPI; +export function getDashboardAPI(requestV2Response: 'v2'): DashboardAPI>; +export function getDashboardAPI(requestV2Response?: 'v2'): DashboardAPI { + const v = getDashboardsApiVersion(); + const isConvertingToV1 = !requestV2Response; + + if (!clients) { + clients = { + legacy: new LegacyDashboardAPI(), + v0: new K8sDashboardAPI(), + v2: new K8sDashboardV2APIStub(isConvertingToV1), + }; + } + + if (v === 'v2' && requestV2Response === 'v2') { + return new K8sDashboardV2APIStub(false); + } + + if (!clients[v]) { + throw new Error(`Unknown Dashboard API version: ${v}`); + } + + return clients[v]; } diff --git a/public/app/features/dashboard/api/legacy.test.ts b/public/app/features/dashboard/api/legacy.test.ts new file mode 100644 index 00000000000..54ceb4d1bf4 --- /dev/null +++ b/public/app/features/dashboard/api/legacy.test.ts @@ -0,0 +1,55 @@ +import { UrlQueryMap } from '@grafana/data'; +import { DashboardDTO } from 'app/types'; + +import { LegacyDashboardAPI } from './legacy'; + +const mockDashboardDto: DashboardDTO = { + meta: { + isFolder: false, + }, + dashboard: { + title: 'test', + uid: 'test', + schemaVersion: 0, + }, +}; + +const backendSrvGetSpy = jest.fn(); +jest.mock('@grafana/runtime', () => ({ + getBackendSrv: () => ({ + get: (dashUrl: string, params: Object) => { + backendSrvGetSpy(dashUrl, params); + const uid = dashUrl.split('/').pop(); + if (uid === 'folderUid') { + return Promise.resolve({ + meta: { + isFolder: true, + }, + }); + } + + return mockDashboardDto; + }, + }), +})); + +jest.mock('app/features/live/dashboard/dashboardWatcher', () => ({ + ignoreNextSave: jest.fn(), +})); + +describe('Legacy dashboard API', () => { + it('should throw an error if requesting a folder', async () => { + const api = new LegacyDashboardAPI(); + expect(async () => await api.getDashboardDTO('folderUid')).rejects.toThrowError('Dashboard not found'); + }); + + it('should return a valid dashboard', async () => { + const api = new LegacyDashboardAPI(); + const params: UrlQueryMap = { + param: 1, + }; + const result = await api.getDashboardDTO('validUid', params); + expect(result).toEqual(mockDashboardDto); + expect(backendSrvGetSpy).toHaveBeenLastCalledWith('/api/dashboards/uid/validUid', params); + }); +}); diff --git a/public/app/features/dashboard/api/legacy.ts b/public/app/features/dashboard/api/legacy.ts new file mode 100644 index 00000000000..66c86ad7511 --- /dev/null +++ b/public/app/features/dashboard/api/legacy.ts @@ -0,0 +1,40 @@ +import { AppEvents, UrlQueryMap } from '@grafana/data'; +import { getBackendSrv } from '@grafana/runtime'; +import appEvents from 'app/core/app_events'; +import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; +import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; +import { SaveDashboardResponseDTO, DashboardDTO } from 'app/types'; + +import { SaveDashboardCommand } from '../components/SaveDashboard/types'; + +import { DashboardAPI } from './types'; + +export class LegacyDashboardAPI implements DashboardAPI { + constructor() {} + + saveDashboard(options: SaveDashboardCommand): Promise { + dashboardWatcher.ignoreNextSave(); + + return getBackendSrv().post('/api/dashboards/db/', { + dashboard: options.dashboard, + message: options.message ?? '', + overwrite: options.overwrite ?? false, + folderUid: options.folderUid, + }); + } + + deleteDashboard(uid: string, showSuccessAlert: boolean): Promise { + return getBackendSrv().delete(`/api/dashboards/uid/${uid}`, { showSuccessAlert }); + } + + async getDashboardDTO(uid: string, params?: UrlQueryMap) { + const result = await getBackendSrv().get(`/api/dashboards/uid/${uid}`, params); + + if (result.meta.isFolder) { + appEvents.emit(AppEvents.alertError, ['Dashboard not found']); + throw new Error('Dashboard not found'); + } + + return result; + } +} diff --git a/public/app/features/dashboard/api/types.ts b/public/app/features/dashboard/api/types.ts new file mode 100644 index 00000000000..db41dab9bf0 --- /dev/null +++ b/public/app/features/dashboard/api/types.ts @@ -0,0 +1,31 @@ +import { UrlQueryMap } from '@grafana/data'; +import { Resource } from 'app/features/apiserver/types'; +import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; +import { AnnotationsPermissions, SaveDashboardResponseDTO } from 'app/types'; + +import { SaveDashboardCommand } from '../components/SaveDashboard/types'; + +export interface DashboardAPI { + /** Get a dashboard with the access control metadata */ + getDashboardDTO(uid: string, params?: UrlQueryMap): Promise; + /** Save dashboard */ + saveDashboard(options: SaveDashboardCommand): Promise; + /** Delete a dashboard */ + deleteDashboard(uid: string, showSuccessAlert: boolean): Promise; +} + +// Implemented using /api/dashboards/* +export interface DashboardWithAccessInfo extends Resource { + access: { + url?: string; + slug?: string; + canSave?: boolean; + canEdit?: boolean; + canDelete?: boolean; + canShare?: boolean; + canStar?: boolean; + canAdmin?: boolean; + annotationsPermissions?: AnnotationsPermissions; + isNew?: boolean; + }; // TODO... +} diff --git a/public/app/features/dashboard/api/utils.test.ts b/public/app/features/dashboard/api/utils.test.ts new file mode 100644 index 00000000000..cad97447ce8 --- /dev/null +++ b/public/app/features/dashboard/api/utils.test.ts @@ -0,0 +1,51 @@ +import { config } from '@grafana/runtime'; + +import { getDashboardsApiVersion } from './utils'; + +describe('getDashboardsApiVersion', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('should return v0 when dashboardScene is disabled and kubernetesDashboards is enabled', () => { + config.featureToggles = { + dashboardScene: false, + kubernetesDashboards: true, + }; + expect(getDashboardsApiVersion()).toBe('v0'); + }); + + it('should return legacy when dashboardScene is disabled and kubernetesDashboards is disabled', () => { + config.featureToggles = { + dashboardScene: false, + kubernetesDashboards: false, + }; + expect(getDashboardsApiVersion()).toBe('legacy'); + }); + + it('should return v2 when dashboardScene is enabled and useV2DashboardsAPI is enabled', () => { + config.featureToggles = { + dashboardScene: true, + useV2DashboardsAPI: true, + }; + expect(getDashboardsApiVersion()).toBe('v2'); + }); + + it('should return v0 when dashboardScene is enabled, useV2DashboardsAPI is disabled, and kubernetesDashboards is enabled', () => { + config.featureToggles = { + dashboardScene: true, + useV2DashboardsAPI: false, + kubernetesDashboards: true, + }; + expect(getDashboardsApiVersion()).toBe('v0'); + }); + + it('should return legacy when dashboardScene is enabled and both useV2DashboardsAPI and kubernetesDashboards are disabled', () => { + config.featureToggles = { + dashboardScene: true, + useV2DashboardsAPI: false, + kubernetesDashboards: false, + }; + expect(getDashboardsApiVersion()).toBe('legacy'); + }); +}); diff --git a/public/app/features/dashboard/api/utils.ts b/public/app/features/dashboard/api/utils.ts new file mode 100644 index 00000000000..092c6b269cf --- /dev/null +++ b/public/app/features/dashboard/api/utils.ts @@ -0,0 +1,47 @@ +import { config } from '@grafana/runtime'; +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { DashboardDataDTO, DashboardDTO } from 'app/types'; + +import { DashboardWithAccessInfo } from './types'; + +export function getDashboardsApiVersion() { + // if dashboard scene is disabled, use legacy API response for the old architecture + if (!config.featureToggles.dashboardScene) { + // for old architecture, use v0 API for k8s dashboards + if (config.featureToggles.kubernetesDashboards) { + return 'v0'; + } + + return 'legacy'; + } + + if (config.featureToggles.useV2DashboardsAPI) { + return 'v2'; + } + + if (config.featureToggles.kubernetesDashboards) { + return 'v0'; + } + + return 'legacy'; +} + +// This function is used to determine if the dashboard is in v2 format or also v0 format +export function isDashboardResource( + obj?: DashboardDTO | DashboardWithAccessInfo | DashboardWithAccessInfo | null +): obj is DashboardWithAccessInfo | DashboardWithAccessInfo { + if (!obj) { + return false; + } + // is v0 or v2 format? + const isK8sDashboard = 'kind' in obj && obj.kind === 'DashboardWithAccessInfo'; + return isK8sDashboard; +} + +export function isDashboardV2Spec(obj: object): obj is DashboardV2Spec { + return 'elements' in obj; +} + +export function isDashboardV0Spec(obj: object): obj is DashboardDataDTO { + return !isDashboardV2Spec(obj); // not v2 spec means it's v0 spec +} diff --git a/public/app/features/dashboard/api/v0.test.ts b/public/app/features/dashboard/api/v0.test.ts new file mode 100644 index 00000000000..f70deaeb347 --- /dev/null +++ b/public/app/features/dashboard/api/v0.test.ts @@ -0,0 +1,65 @@ +import { backendSrv } from 'app/core/services/backend_srv'; +import { AnnoKeyFolder } from 'app/features/apiserver/types'; +import { DashboardDataDTO } from 'app/types'; + +import { DashboardWithAccessInfo } from './types'; +import { K8sDashboardAPI } from './v0'; + +const mockDashboardDto: DashboardWithAccessInfo = { + kind: 'DashboardWithAccessInfo', + apiVersion: 'v0alpha1', + + metadata: { + name: 'dash-uid', + resourceVersion: '1', + creationTimestamp: '1', + annotations: { + [AnnoKeyFolder]: 'new-folder', + }, + }, + spec: { + title: 'test', + uid: 'test', + schemaVersion: 0, + }, + access: {}, +}; + +jest.mock('@grafana/runtime', () => ({ + getBackendSrv: () => ({ + get: () => mockDashboardDto, + }), + config: {}, +})); + +jest.mock('app/features/live/dashboard/dashboardWatcher', () => ({ + ignoreNextSave: jest.fn(), +})); + +describe('v0 dashboard API', () => { + it('should provide folder annotations', async () => { + jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValue({ + id: 1, + uid: 'new-folder', + title: 'New Folder', + url: '/folder/url', + canAdmin: true, + canDelete: true, + canEdit: true, + canSave: true, + created: '', + createdBy: '', + hasAcl: false, + updated: '', + updatedBy: '', + }); + + const api = new K8sDashboardAPI(); + const result = await api.getDashboardDTO('test'); + expect(result.meta.isFolder).toBe(false); + expect(result.meta.folderId).toBe(1); + expect(result.meta.folderTitle).toBe('New Folder'); + expect(result.meta.folderUrl).toBe('/folder/url'); + expect(result.meta.folderUid).toBe('new-folder'); + }); +}); diff --git a/public/app/features/dashboard/api/v0.ts b/public/app/features/dashboard/api/v0.ts new file mode 100644 index 00000000000..0f513f80174 --- /dev/null +++ b/public/app/features/dashboard/api/v0.ts @@ -0,0 +1,109 @@ +import { backendSrv } from 'app/core/services/backend_srv'; +import { ScopedResourceClient } from 'app/features/apiserver/client'; +import { + ResourceClient, + ResourceForCreate, + AnnoKeyMessage, + AnnoKeyFolder, + Resource, +} from 'app/features/apiserver/types'; +import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; +import { DashboardDataDTO, DashboardDTO, SaveDashboardResponseDTO } from 'app/types'; + +import { SaveDashboardCommand } from '../components/SaveDashboard/types'; + +import { DashboardAPI, DashboardWithAccessInfo } from './types'; + +export class K8sDashboardAPI implements DashboardAPI { + private client: ResourceClient; + + constructor() { + this.client = new ScopedResourceClient({ + group: 'dashboard.grafana.app', + version: 'v0alpha1', + resource: 'dashboards', + }); + } + + saveDashboard(options: SaveDashboardCommand): Promise { + const dashboard = options.dashboard as DashboardDataDTO; // type for the uid property + const obj: ResourceForCreate = { + metadata: { + ...options?.k8s, + }, + spec: { + ...dashboard, + }, + }; + + if (options.message) { + obj.metadata.annotations = { + ...obj.metadata.annotations, + [AnnoKeyMessage]: options.message, + }; + } else if (obj.metadata.annotations) { + delete obj.metadata.annotations[AnnoKeyMessage]; + } + + if (options.folderUid) { + obj.metadata.annotations = { + ...obj.metadata.annotations, + [AnnoKeyFolder]: options.folderUid, + }; + } + + if (dashboard.uid) { + obj.metadata.name = dashboard.uid; + return this.client.update(obj).then((v) => this.asSaveDashboardResponseDTO(v)); + } + return this.client.create(obj).then((v) => this.asSaveDashboardResponseDTO(v)); + } + + asSaveDashboardResponseDTO(v: Resource): SaveDashboardResponseDTO { + return { + uid: v.metadata.name, + version: v.spec.version ?? 0, + id: v.spec.id ?? 0, + status: 'success', + slug: '', + url: '', + }; + } + + deleteDashboard(uid: string, showSuccessAlert: boolean): Promise { + return this.client.delete(uid).then((v) => ({ + id: 0, + message: v.message, + title: 'deleted', + })); + } + + async getDashboardDTO(uid: string) { + const dash = await this.client.subresource>(uid, 'dto'); + + const result: DashboardDTO = { + meta: { + ...dash.access, + isNew: false, + isFolder: false, + uid: dash.metadata.name, + k8s: dash.metadata, + }, + dashboard: dash.spec, + }; + + if (dash.metadata.annotations?.[AnnoKeyFolder]) { + try { + const folder = await backendSrv.getFolderByUid(dash.metadata.annotations[AnnoKeyFolder]); + result.meta.folderTitle = folder.title; + result.meta.folderUrl = folder.url; + result.meta.folderUid = folder.uid; + result.meta.folderId = folder.id; + } catch (e) { + console.error('Failed to load a folder', e); + } + } + + return result; + } +} diff --git a/public/app/features/dashboard/api/v2.test.ts b/public/app/features/dashboard/api/v2.test.ts new file mode 100644 index 00000000000..a3d0bdec9ca --- /dev/null +++ b/public/app/features/dashboard/api/v2.test.ts @@ -0,0 +1,69 @@ +import { + DashboardV2Spec, + defaultDashboardV2Spec, +} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { AnnoKeyFolder, AnnoKeyFolderId, AnnoKeyFolderTitle, AnnoKeyFolderUrl } from 'app/features/apiserver/types'; + +import { DashboardWithAccessInfo } from './types'; +import { K8sDashboardV2APIStub } from './v2'; + +const mockDashboardDto: DashboardWithAccessInfo = { + kind: 'DashboardWithAccessInfo', + apiVersion: 'v0alpha1', + + metadata: { + name: 'dash-uid', + resourceVersion: '1', + creationTimestamp: '1', + annotations: { + [AnnoKeyFolder]: 'new-folder', + }, + }, + spec: { + ...defaultDashboardV2Spec(), + }, + access: {}, +}; + +jest.mock('@grafana/runtime', () => ({ + getBackendSrv: () => ({ + get: () => mockDashboardDto, + }), + config: {}, +})); + +jest.mock('app/features/live/dashboard/dashboardWatcher', () => ({ + ignoreNextSave: jest.fn(), +})); + +describe('v2 dashboard API', () => { + it('should provide folder annotations', async () => { + jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValue({ + id: 1, + uid: 'new-folder', + title: 'New Folder', + url: '/folder/url', + canAdmin: true, + canDelete: true, + canEdit: true, + canSave: true, + created: '', + createdBy: '', + hasAcl: false, + updated: '', + updatedBy: '', + }); + + const convertToV1 = false; + const api = new K8sDashboardV2APIStub(convertToV1); + // because the API can currently return both DashboardDTO and DashboardWithAccessInfo based on the + // parameter convertToV1, we need to cast the result to DashboardWithAccessInfo to be able to + // access + const result = (await api.getDashboardDTO('test')) as DashboardWithAccessInfo; + expect(result.metadata.annotations![AnnoKeyFolderId]).toBe(1); + expect(result.metadata.annotations![AnnoKeyFolderTitle]).toBe('New Folder'); + expect(result.metadata.annotations![AnnoKeyFolderUrl]).toBe('/folder/url'); + expect(result.metadata.annotations![AnnoKeyFolder]).toBe('new-folder'); + }); +}); diff --git a/public/app/features/dashboard/api/v2.ts b/public/app/features/dashboard/api/v2.ts new file mode 100644 index 00000000000..2ee98a65e76 --- /dev/null +++ b/public/app/features/dashboard/api/v2.ts @@ -0,0 +1,68 @@ +import { UrlQueryMap } from '@grafana/data'; +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { ScopedResourceClient } from 'app/features/apiserver/client'; +import { + AnnoKeyFolder, + AnnoKeyFolderId, + AnnoKeyFolderTitle, + AnnoKeyFolderUrl, + ResourceClient, +} from 'app/features/apiserver/types'; +import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; +import { DashboardDTO, SaveDashboardResponseDTO } from 'app/types'; + +import { SaveDashboardCommand } from '../components/SaveDashboard/types'; + +import { ResponseTransformers } from './ResponseTransformers'; +import { DashboardAPI, DashboardWithAccessInfo } from './types'; + +export class K8sDashboardV2APIStub implements DashboardAPI | DashboardDTO> { + private client: ResourceClient; + + constructor(private convertToV1: boolean) { + this.client = new ScopedResourceClient({ + group: 'dashboard.grafana.app', + version: 'v2alpha1', + resource: 'dashboards', + }); + } + + async getDashboardDTO(uid: string, params?: UrlQueryMap) { + const dashboard = await this.client.subresource>(uid, 'dto'); + + let result: DashboardWithAccessInfo | DashboardDTO | undefined; + + // TODO: For dev purposes only, the conversion should and will happen in the API. This is just to stub v2 api responses. + result = ResponseTransformers.ensureV2Response(dashboard); + + // load folder info if available + if (result.metadata.annotations && result.metadata.annotations[AnnoKeyFolder]) { + try { + const folder = await backendSrv.getFolderByUid(result.metadata.annotations[AnnoKeyFolder]); + result.metadata.annotations[AnnoKeyFolderTitle] = folder.title; + result.metadata.annotations[AnnoKeyFolderUrl] = folder.url; + result.metadata.annotations[AnnoKeyFolderId] = folder.id; + } catch (e) { + console.error('Failed to load a folder', e); + } + } + + // Depending on the ui components readiness, we might need to convert the response to v1 + if (this.convertToV1) { + // Always return V1 format + result = ResponseTransformers.ensureV1Response(result); + return result; + } + // return the v2 response + return result; + } + + deleteDashboard(uid: string, showSuccessAlert: boolean): Promise { + throw new Error('Method not implemented.'); + } + + saveDashboard(options: SaveDashboardCommand): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/public/app/features/manage-dashboards/utils/validation.test.ts b/public/app/features/manage-dashboards/utils/validation.test.ts new file mode 100644 index 00000000000..3f2b2bade41 --- /dev/null +++ b/public/app/features/manage-dashboards/utils/validation.test.ts @@ -0,0 +1,64 @@ +import { + DashboardV2Spec, + defaultDashboardV2Spec, +} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { AnnoKeyFolder, AnnoKeyFolderTitle } from 'app/features/apiserver/types'; +import { setDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; +import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; +import { DashboardDTO } from 'app/types'; + +import { validateUid } from './validation'; + +const legacyDashboard: DashboardDTO = { + dashboard: { + title: 'Legacy Dashboard', + schemaVersion: 16, + uid: 'dashboard-uid', + }, + meta: { + folderTitle: 'Folder title', + folderUid: 'folder-uid', + }, +}; + +const v2Dashboard: DashboardWithAccessInfo = { + kind: 'DashboardWithAccessInfo', + apiVersion: 'v2alpha0', + metadata: { + creationTimestamp: '2021-09-29T14:00:00Z', + name: 'dashboard-uid', + resourceVersion: '1', + annotations: { + [AnnoKeyFolder]: 'folder-uid', + [AnnoKeyFolderTitle]: 'folder-title', + }, + }, + access: {}, + spec: { + ...defaultDashboardV2Spec(), + title: 'V2 Dashboard', + }, +}; + +describe('validateUid', () => { + beforeAll(() => { + setDashboardAPI({ + legacy: { + getDashboardDTO: jest.fn().mockResolvedValue(legacyDashboard), + deleteDashboard: jest.fn(), + saveDashboard: jest.fn(), + }, + v2: { + getDashboardDTO: jest.fn().mockResolvedValue(v2Dashboard), + deleteDashboard: jest.fn(), + saveDashboard: jest.fn(), + }, + }); + }); + describe('Dashboards API v1', () => { + it('should return a message with the existing dashboard title and folder title', async () => { + const result = await validateUid('dashboard-uid'); + expect(result).toBe(`Dashboard named 'Legacy Dashboard' in folder 'Folder title' has the same UID`); + }); + }); +}); diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index b0bc619efd5..06a823d3025 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -56,6 +56,7 @@ export interface DashboardMeta { isSnapshot?: boolean; folderTitle?: string; folderUrl?: string; + folderId?: number; created?: string; createdBy?: string; updated?: string;