- {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/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,
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/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/apis/folder/v0alpha1/types.go b/pkg/apis/folder/v0alpha1/types.go
index 126a4f9f80e..2074ba1aad5 100644
--- a/pkg/apis/folder/v0alpha1/types.go
+++ b/pkg/apis/folder/v0alpha1/types.go
@@ -46,14 +46,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 9b004096c7c..e8324ade423 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/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=
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)
}
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
}
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/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/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/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)
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", "", ""},
}
)
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": ""
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 6f89b313b61..8ac5b531095 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 {
@@ -73,7 +74,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 3ad1d6e6a21..96add87e611 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/__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/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 f8ed28b694c..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';
@@ -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..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';
@@ -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/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 (
);
}
+
+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-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);
}
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/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/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/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 });
}}
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));
});
});
});
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/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', {
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/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 (
);
}}
diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts
index e6771ab1ff8..92300d7b623 100644
--- a/public/app/types/dashboard.ts
+++ b/public/app/types/dashboard.ts
@@ -57,6 +57,7 @@ export interface DashboardMeta {
isSnapshot?: boolean;
folderTitle?: string;
folderUrl?: string;
+ folderId?: number;
created?: string;
createdBy?: string;
updated?: string;
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/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/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/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şĥőŧ"
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": {
diff --git a/yarn.lock b/yarn.lock
index 1ca08ba41c9..d4e4e1f2915 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:*"
@@ -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"
@@ -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
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -3829,11 +3829,11 @@ __metadata:
languageName: unknown
linkType: soft
-"@grafana/scenes-react@npm:^5.32.0":
- version: 5.32.0
- resolution: "@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:
- "@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.34.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
@@ -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"
@@ -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"
@@ -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
@@ -10147,15 +10147,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"
@@ -10163,28 +10163,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
@@ -10198,38 +10194,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
@@ -10240,17 +10226,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
@@ -10272,55 +10251,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
@@ -10342,20 +10302,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"
@@ -10366,29 +10312,19 @@ __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
+ "@typescript-eslint/types": "npm:8.18.1"
+ eslint-visitor-keys: "npm:^4.2.0"
+ checksum: 10/00e88b1640a68c3afea08731395eb09a8216892248fee819cb7526e99093256743239d6b9e880a499f1c0ddfe2ffa4d1ad895d9e778b5d42e702d5880db1a594
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
- 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:*"
@@ -10396,82 +10332,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"
@@ -10479,29 +10415,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"
@@ -10522,7 +10458,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
@@ -16001,7 +15937,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
@@ -17604,7 +17540,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"
@@ -17617,8 +17553,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"
@@ -17718,13 +17654,13 @@ __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"
- "@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"
+ "@typescript-eslint/eslint-plugin": "npm:8.18.1"
+ "@typescript-eslint/parser": "npm:8.18.1"
+ "@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"
@@ -20764,8 +20700,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"
@@ -20789,7 +20725,7 @@ __metadata:
bin:
knip: bin/knip.js
knip-bun: bin/knip-bun.js
- checksum: 10/a30b986ca4331b735f188c9217cb551b2698ddbed54bc98f1bf3c1fe1e85b3aff589244dafe60d6b5c7a8ee5133726d69366965cc3ae6299b43c33787155a7a2
+ checksum: 10/238d76428a1f0cd3a184ebd9aaea2e07b3b8434cdee6c6e9230d6ae31f18b93188e2b60fbd64769d2448ce5b0114e662ee536843207514b1e95060b8740218d8
languageName: node
linkType: hard
@@ -29762,9 +29698,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
@@ -31312,11 +31248,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