merge main
This commit is contained in:
commit
1420e059d8
178 files changed
+3233
-2158
No files matched your search
@@ -10,6 +10,12 @@ check: {
|
||||
frontend: false
|
||||
backend: true
|
||||
}
|
||||
validation: {
|
||||
operations: [
|
||||
"CREATE",
|
||||
"UPDATE",
|
||||
]
|
||||
}
|
||||
schema: {
|
||||
spec: {
|
||||
// Generic data input that a check can receive
|
||||
|
||||
@@ -27,7 +27,15 @@ var appManifestData = app.ManifestData{
|
||||
Conversion: false,
|
||||
Versions: []app.ManifestKindVersion{
|
||||
{
|
||||
Name: "v0alpha1",
|
||||
Name: "v0alpha1",
|
||||
Admission: &app.AdmissionCapabilities{
|
||||
Validation: &app.ValidationCapability{
|
||||
Operations: []app.AdmissionOperation{
|
||||
app.AdmissionOperationCreate,
|
||||
app.AdmissionOperationUpdate,
|
||||
},
|
||||
},
|
||||
},
|
||||
Schema: &versionSchemaCheckv0alpha1,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,16 +2,44 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
"github.com/grafana/grafana-app-sdk/k8s"
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
"github.com/grafana/grafana-app-sdk/simple"
|
||||
advisorv0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry"
|
||||
"github.com/grafana/grafana/apps/advisor/pkg/app/checks"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
typeLabel = "advisor.grafana.app/type"
|
||||
statusAnnotation = "advisor.grafana.app/status"
|
||||
)
|
||||
|
||||
func New(cfg app.Config) (app.App, error) {
|
||||
// Read config
|
||||
checkRegistry, ok := cfg.SpecificConfig.(checkregistry.CheckService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid config type")
|
||||
}
|
||||
|
||||
// Prepare storage client
|
||||
clientGenerator := k8s.NewClientRegistry(cfg.KubeConfig, k8s.ClientConfig{})
|
||||
client, err := clientGenerator.ClientFor(advisorv0alpha1.CheckKind())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize checks
|
||||
checkMap := map[string]checks.Check{}
|
||||
for _, c := range checkRegistry.Checks() {
|
||||
checkMap[c.Type()] = c
|
||||
}
|
||||
|
||||
simpleConfig := simple.AppConfig{
|
||||
Name: "advisor",
|
||||
KubeConfig: cfg.KubeConfig,
|
||||
@@ -23,6 +51,24 @@ func New(cfg app.Config) (app.App, error) {
|
||||
ManagedKinds: []simple.AppManagedKind{
|
||||
{
|
||||
Kind: advisorv0alpha1.CheckKind(),
|
||||
Validator: &simple.Validator{
|
||||
ValidateFunc: func(ctx context.Context, req *app.AdmissionRequest) error {
|
||||
if req.Object != nil {
|
||||
_, err := getCheck(req.Object, checkMap)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Watcher: &simple.Watcher{
|
||||
AddFunc: func(ctx context.Context, obj resource.Object) error {
|
||||
check, err := getCheck(obj, checkMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return processCheck(ctx, client, obj, check)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
)
|
||||
|
||||
func GetAuthorizer() authorizer.Authorizer {
|
||||
return authorizer.AuthorizerFunc(func(
|
||||
ctx context.Context, attr authorizer.Attributes,
|
||||
) (authorized authorizer.Decision, reason string, err error) {
|
||||
if !attr.IsResourceRequest() {
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
}
|
||||
|
||||
// require a user
|
||||
u, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "valid user is required", err
|
||||
}
|
||||
|
||||
// check if is admin
|
||||
if u.GetIsGrafanaAdmin() {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
|
||||
return authorizer.DecisionDeny, "forbidden", nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
)
|
||||
|
||||
func TestGetAuthorizer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
attr authorizer.Attributes
|
||||
expectedDecision authorizer.Decision
|
||||
expectedReason string
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
name: "non-resource request",
|
||||
ctx: context.TODO(),
|
||||
attr: &mockAttributes{resourceRequest: false},
|
||||
expectedDecision: authorizer.DecisionNoOpinion,
|
||||
expectedReason: "",
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "user is admin",
|
||||
ctx: identity.WithRequester(context.TODO(), &mockUser{isGrafanaAdmin: true}),
|
||||
attr: &mockAttributes{resourceRequest: true},
|
||||
expectedDecision: authorizer.DecisionAllow,
|
||||
expectedReason: "",
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "user is not admin",
|
||||
ctx: identity.WithRequester(context.TODO(), &mockUser{isGrafanaAdmin: false}),
|
||||
attr: &mockAttributes{resourceRequest: true},
|
||||
expectedDecision: authorizer.DecisionDeny,
|
||||
expectedReason: "forbidden",
|
||||
expectedErr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
auth := GetAuthorizer()
|
||||
decision, reason, err := auth.Authorize(tt.ctx, tt.attr)
|
||||
assert.Equal(t, tt.expectedDecision, decision)
|
||||
assert.Equal(t, tt.expectedReason, reason)
|
||||
assert.Equal(t, tt.expectedErr, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockAttributes struct {
|
||||
authorizer.Attributes
|
||||
resourceRequest bool
|
||||
}
|
||||
|
||||
func (m *mockAttributes) IsResourceRequest() bool {
|
||||
return m.resourceRequest
|
||||
}
|
||||
|
||||
// Implement other methods of authorizer.Attributes as needed
|
||||
|
||||
type mockUser struct {
|
||||
identity.Requester
|
||||
isGrafanaAdmin bool
|
||||
}
|
||||
|
||||
func (m *mockUser) GetIsGrafanaAdmin() bool {
|
||||
return m.isGrafanaAdmin
|
||||
}
|
||||
|
||||
// Implement other methods of identity.Requester as needed
|
||||
@@ -0,0 +1,42 @@
|
||||
package checkregistry
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/apps/advisor/pkg/app/checks"
|
||||
"github.com/grafana/grafana/apps/advisor/pkg/app/checks/datasourcecheck"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/datasource"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
)
|
||||
|
||||
type CheckService interface {
|
||||
Checks() []checks.Check
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
datasourceSvc datasources.DataSourceService
|
||||
pluginStore pluginstore.Store
|
||||
pluginContextProvider datasource.PluginContextWrapper
|
||||
pluginClient plugins.Client
|
||||
}
|
||||
|
||||
func ProvideService(datasourceSvc datasources.DataSourceService, pluginStore pluginstore.Store,
|
||||
pluginContextProvider datasource.PluginContextWrapper, pluginClient plugins.Client) *Service {
|
||||
return &Service{
|
||||
datasourceSvc: datasourceSvc,
|
||||
pluginStore: pluginStore,
|
||||
pluginContextProvider: pluginContextProvider,
|
||||
pluginClient: pluginClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Checks() []checks.Check {
|
||||
return []checks.Check{
|
||||
datasourcecheck.New(
|
||||
s.datasourceSvc,
|
||||
s.pluginStore,
|
||||
s.pluginContextProvider,
|
||||
s.pluginClient,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package datasourcecheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
advisor "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/advisor/pkg/app/checks"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/datasource"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
func New(
|
||||
datasourceSvc datasources.DataSourceService,
|
||||
pluginStore pluginstore.Store,
|
||||
pluginContextProvider datasource.PluginContextWrapper,
|
||||
pluginClient plugins.Client,
|
||||
) checks.Check {
|
||||
return &check{
|
||||
DatasourceSvc: datasourceSvc,
|
||||
PluginStore: pluginStore,
|
||||
PluginContextProvider: pluginContextProvider,
|
||||
PluginClient: pluginClient,
|
||||
}
|
||||
}
|
||||
|
||||
type check struct {
|
||||
DatasourceSvc datasources.DataSourceService
|
||||
PluginStore pluginstore.Store
|
||||
PluginContextProvider datasource.PluginContextWrapper
|
||||
PluginClient plugins.Client
|
||||
}
|
||||
|
||||
func (c *check) Type() string {
|
||||
return "datasource"
|
||||
}
|
||||
|
||||
func (c *check) Run(ctx context.Context, obj *advisor.CheckSpec) (*advisor.CheckV0alpha1StatusReport, error) {
|
||||
// Optionally read the check input encoded in the object
|
||||
// fmt.Println(obj.Data)
|
||||
|
||||
dss, err := c.DatasourceSvc.GetAllDataSources(ctx, &datasources.GetAllDataSourcesQuery{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dsErrs := []advisor.CheckV0alpha1StatusReportErrors{}
|
||||
for _, ds := range dss {
|
||||
// Data source UID validation
|
||||
err := util.ValidateUID(ds.UID)
|
||||
if err != nil {
|
||||
dsErrs = append(dsErrs, advisor.CheckV0alpha1StatusReportErrors{
|
||||
Severity: advisor.CheckStatusSeverityLow,
|
||||
Reason: fmt.Sprintf("Invalid UID: %s", ds.UID),
|
||||
Action: "Change UID",
|
||||
})
|
||||
}
|
||||
|
||||
// Health check execution
|
||||
pCtx, err := c.PluginContextProvider.PluginContextForDataSource(ctx, &backend.DataSourceInstanceSettings{
|
||||
Type: ds.Type,
|
||||
UID: ds.UID,
|
||||
APIVersion: ds.APIVersion,
|
||||
})
|
||||
if err != nil {
|
||||
klog.ErrorS(err, "Error creating plugin context", "datasource", ds.Name)
|
||||
continue
|
||||
}
|
||||
req := &backend.CheckHealthRequest{
|
||||
PluginContext: pCtx,
|
||||
Headers: map[string]string{},
|
||||
}
|
||||
resp, err := c.PluginClient.CheckHealth(ctx, req)
|
||||
if err != nil {
|
||||
fmt.Println("Error checking health", err)
|
||||
continue
|
||||
}
|
||||
if resp.Status != backend.HealthStatusOk {
|
||||
dsErrs = append(dsErrs, advisor.CheckV0alpha1StatusReportErrors{
|
||||
Severity: advisor.CheckStatusSeverityHigh,
|
||||
Reason: fmt.Sprintf("Health check failed: %s", ds.Name),
|
||||
Action: "Check datasource",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &advisor.CheckV0alpha1StatusReport{
|
||||
Count: int64(len(dss)),
|
||||
Errors: dsErrs,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package datasourcecheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
advisor "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/datasource"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCheck_Run(t *testing.T) {
|
||||
t.Run("should return no errors when all datasources are healthy", func(t *testing.T) {
|
||||
datasources := []*datasources.DataSource{
|
||||
{UID: "valid-uid-1", Type: "prometheus", Name: "Prometheus"},
|
||||
{UID: "valid-uid-2", Type: "mysql", Name: "MySQL"},
|
||||
}
|
||||
|
||||
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
|
||||
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
|
||||
mockPluginClient := &MockPluginClient{res: &backend.CheckHealthResult{Status: backend.HealthStatusOk}}
|
||||
|
||||
check := &check{
|
||||
DatasourceSvc: mockDatasourceSvc,
|
||||
PluginContextProvider: mockPluginContextProvider,
|
||||
PluginClient: mockPluginClient,
|
||||
}
|
||||
|
||||
report, err := check.Run(context.Background(), &advisor.CheckSpec{})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(2), report.Count)
|
||||
assert.Empty(t, report.Errors)
|
||||
})
|
||||
|
||||
t.Run("should return errors when datasource UID is invalid", func(t *testing.T) {
|
||||
datasources := []*datasources.DataSource{
|
||||
{UID: "invalid uid", Type: "prometheus", Name: "Prometheus"},
|
||||
}
|
||||
|
||||
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
|
||||
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
|
||||
mockPluginClient := &MockPluginClient{res: &backend.CheckHealthResult{Status: backend.HealthStatusOk}}
|
||||
|
||||
check := &check{
|
||||
DatasourceSvc: mockDatasourceSvc,
|
||||
PluginContextProvider: mockPluginContextProvider,
|
||||
PluginClient: mockPluginClient,
|
||||
}
|
||||
|
||||
report, err := check.Run(context.Background(), &advisor.CheckSpec{})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(1), report.Count)
|
||||
assert.Len(t, report.Errors, 1)
|
||||
assert.Equal(t, "Invalid UID: invalid uid", report.Errors[0].Reason)
|
||||
})
|
||||
|
||||
t.Run("should return errors when datasource health check fails", func(t *testing.T) {
|
||||
datasources := []*datasources.DataSource{
|
||||
{UID: "valid-uid-1", Type: "prometheus", Name: "Prometheus"},
|
||||
}
|
||||
|
||||
mockDatasourceSvc := &MockDatasourceSvc{dss: datasources}
|
||||
mockPluginContextProvider := &MockPluginContextProvider{pCtx: backend.PluginContext{}}
|
||||
mockPluginClient := &MockPluginClient{res: &backend.CheckHealthResult{Status: backend.HealthStatusError}}
|
||||
|
||||
check := &check{
|
||||
DatasourceSvc: mockDatasourceSvc,
|
||||
PluginContextProvider: mockPluginContextProvider,
|
||||
PluginClient: mockPluginClient,
|
||||
}
|
||||
|
||||
report, err := check.Run(context.Background(), &advisor.CheckSpec{})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(1), report.Count)
|
||||
assert.Len(t, report.Errors, 1)
|
||||
assert.Equal(t, "Health check failed: Prometheus", report.Errors[0].Reason)
|
||||
})
|
||||
}
|
||||
|
||||
type MockDatasourceSvc struct {
|
||||
datasources.DataSourceService
|
||||
|
||||
dss []*datasources.DataSource
|
||||
}
|
||||
|
||||
func (m *MockDatasourceSvc) GetAllDataSources(ctx context.Context, query *datasources.GetAllDataSourcesQuery) ([]*datasources.DataSource, error) {
|
||||
return m.dss, nil
|
||||
}
|
||||
|
||||
type MockPluginContextProvider struct {
|
||||
datasource.PluginContextWrapper
|
||||
|
||||
pCtx backend.PluginContext
|
||||
}
|
||||
|
||||
func (m *MockPluginContextProvider) PluginContextForDataSource(ctx context.Context, datasourceSettings *backend.DataSourceInstanceSettings) (backend.PluginContext, error) {
|
||||
return m.pCtx, nil
|
||||
}
|
||||
|
||||
type MockPluginClient struct {
|
||||
plugins.Client
|
||||
|
||||
res *backend.CheckHealthResult
|
||||
}
|
||||
|
||||
func (m *MockPluginClient) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
|
||||
return m.res, nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
advisorv0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
|
||||
)
|
||||
|
||||
// Check defines the methods that a check must implement to be executed.
|
||||
type Check interface {
|
||||
Run(ctx context.Context, obj *advisorv0alpha1.CheckSpec) (*advisorv0alpha1.CheckV0alpha1StatusReport, error)
|
||||
Type() string
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
claims "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
advisorv0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/advisor/pkg/app/checks"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
|
||||
func getCheck(obj resource.Object, checks map[string]checks.Check) (checks.Check, error) {
|
||||
labels := obj.GetLabels()
|
||||
objTypeLabel, ok := labels[typeLabel]
|
||||
if !ok {
|
||||
return nil, errors.New("missing check type as label")
|
||||
}
|
||||
c, ok := checks[objTypeLabel]
|
||||
if !ok {
|
||||
supportedTypes := ""
|
||||
for k := range checks {
|
||||
supportedTypes += k + ", "
|
||||
}
|
||||
return nil, fmt.Errorf("unknown check type %s. Supported types are: %s", objTypeLabel, supportedTypes)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func getStatusAnnotation(obj resource.Object) string {
|
||||
return obj.GetAnnotations()[statusAnnotation]
|
||||
}
|
||||
|
||||
func setStatusAnnotation(ctx context.Context, client resource.Client, obj resource.Object, status string) error {
|
||||
annotations := obj.GetAnnotations()
|
||||
annotations[statusAnnotation] = status
|
||||
return client.PatchInto(ctx, obj.GetStaticMetadata().Identifier(), resource.PatchRequest{
|
||||
Operations: []resource.PatchOperation{{
|
||||
Operation: resource.PatchOpAdd,
|
||||
Path: "/metadata/annotations",
|
||||
Value: annotations,
|
||||
}},
|
||||
}, resource.PatchOptions{}, obj)
|
||||
}
|
||||
|
||||
func processCheck(ctx context.Context, client resource.Client, obj resource.Object, check checks.Check) error {
|
||||
status := getStatusAnnotation(obj)
|
||||
if status != "" {
|
||||
// Check already processed
|
||||
return nil
|
||||
}
|
||||
c, ok := obj.(*advisorv0alpha1.Check)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid object type")
|
||||
}
|
||||
// Populate ctx with the user that created the check
|
||||
meta, err := utils.MetaAccessor(obj)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
createdBy := meta.GetCreatedBy()
|
||||
typ, uid, err := claims.ParseTypeID(createdBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = identity.WithRequester(ctx, &user.SignedInUser{
|
||||
UserUID: uid,
|
||||
FallbackType: typ,
|
||||
})
|
||||
// Run the checks
|
||||
report, err := check.Run(ctx, &c.Spec)
|
||||
if err != nil {
|
||||
setErr := setStatusAnnotation(ctx, client, obj, "error")
|
||||
if setErr != nil {
|
||||
return setErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
err = setStatusAnnotation(ctx, client, obj, "processed")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.PatchInto(ctx, obj.GetStaticMetadata().Identifier(), resource.PatchRequest{
|
||||
Operations: []resource.PatchOperation{{
|
||||
Operation: resource.PatchOpAdd,
|
||||
Path: "/status/report",
|
||||
Value: *report,
|
||||
}},
|
||||
}, resource.PatchOptions{}, obj)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
advisorv0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/advisor/pkg/app/checks"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetCheck(t *testing.T) {
|
||||
obj := &advisorv0alpha1.Check{}
|
||||
obj.SetLabels(map[string]string{typeLabel: "testType"})
|
||||
|
||||
checkMap := map[string]checks.Check{
|
||||
"testType": &mockCheck{},
|
||||
}
|
||||
|
||||
check, err := getCheck(obj, checkMap)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, check)
|
||||
}
|
||||
|
||||
func TestGetCheck_MissingLabel(t *testing.T) {
|
||||
obj := &advisorv0alpha1.Check{}
|
||||
checkMap := map[string]checks.Check{}
|
||||
|
||||
_, err := getCheck(obj, checkMap)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "missing check type as label", err.Error())
|
||||
}
|
||||
|
||||
func TestGetCheck_UnknownType(t *testing.T) {
|
||||
obj := &advisorv0alpha1.Check{}
|
||||
obj.SetLabels(map[string]string{typeLabel: "unknownType"})
|
||||
|
||||
checkMap := map[string]checks.Check{
|
||||
"testType": &mockCheck{},
|
||||
}
|
||||
|
||||
_, err := getCheck(obj, checkMap)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unknown check type unknownType")
|
||||
}
|
||||
|
||||
func TestSetStatusAnnotation(t *testing.T) {
|
||||
obj := &advisorv0alpha1.Check{}
|
||||
obj.SetAnnotations(map[string]string{})
|
||||
client := &mockClient{}
|
||||
ctx := context.TODO()
|
||||
|
||||
err := setStatusAnnotation(ctx, client, obj, "processed")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "processed", obj.GetAnnotations()[statusAnnotation])
|
||||
}
|
||||
|
||||
func TestProcessCheck(t *testing.T) {
|
||||
obj := &advisorv0alpha1.Check{}
|
||||
obj.SetAnnotations(map[string]string{})
|
||||
meta, err := utils.MetaAccessor(obj)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.SetCreatedBy("user:1")
|
||||
client := &mockClient{}
|
||||
ctx := context.TODO()
|
||||
check := &mockCheck{}
|
||||
|
||||
err = processCheck(ctx, client, obj, check)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "processed", obj.GetAnnotations()[statusAnnotation])
|
||||
}
|
||||
|
||||
func TestProcessCheck_AlreadyProcessed(t *testing.T) {
|
||||
obj := &advisorv0alpha1.Check{}
|
||||
obj.SetAnnotations(map[string]string{statusAnnotation: "processed"})
|
||||
client := &mockClient{}
|
||||
ctx := context.TODO()
|
||||
check := &mockCheck{}
|
||||
|
||||
err := processCheck(ctx, client, obj, check)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestProcessCheck_RunError(t *testing.T) {
|
||||
obj := &advisorv0alpha1.Check{}
|
||||
obj.SetAnnotations(map[string]string{})
|
||||
meta, err := utils.MetaAccessor(obj)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.SetCreatedBy("user:1")
|
||||
client := &mockClient{}
|
||||
ctx := context.TODO()
|
||||
|
||||
check := &mockCheck{
|
||||
err: errors.New("run error"),
|
||||
}
|
||||
|
||||
err = processCheck(ctx, client, obj, check)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "error", obj.GetAnnotations()[statusAnnotation])
|
||||
}
|
||||
|
||||
type mockClient struct {
|
||||
resource.Client
|
||||
}
|
||||
|
||||
func (m *mockClient) PatchInto(ctx context.Context, id resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions, obj resource.Object) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockCheck struct {
|
||||
checks.Check
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockCheck) Run(ctx context.Context, spec *advisorv0alpha1.CheckSpec) (*advisorv0alpha1.CheckV0alpha1StatusReport, error) {
|
||||
return &advisorv0alpha1.CheckV0alpha1StatusReport{}, m.err
|
||||
}
|
||||
@@ -149,6 +149,10 @@ The **Page size** option lets you paginate the state timeline visualization to l
|
||||
|
||||
{{< docs/shared lookup="visualizations/tooltip-options-1.md" source="grafana" version="<GRAFANA_VERSION>" leveloffset="+1" >}}
|
||||
|
||||
### Axis options
|
||||
|
||||
{{< docs/shared lookup="visualizations/axis-options-state-status.md" source="grafana" version="<GRAFANA_VERSION>" leveloffset="+1" >}}
|
||||
|
||||
### Standard options
|
||||
|
||||
{{< docs/shared lookup="visualizations/standard-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
@@ -131,6 +131,10 @@ Controls the opacity of state regions.
|
||||
|
||||
{{< docs/shared lookup="visualizations/tooltip-options-1.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
## Axis options
|
||||
|
||||
{{< docs/shared lookup="visualizations/axis-options-state-status.md" source="grafana" version="<GRAFANA_VERSION>" leveloffset="+1" >}}
|
||||
|
||||
## Standard options
|
||||
|
||||
{{< docs/shared lookup="visualizations/standard-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
@@ -209,21 +209,7 @@ The following example shows three series: Min, Max, and Value. The Min and Max s
|
||||
|
||||
### Axis options
|
||||
|
||||
Options under the **Axis** section control how the x- and y-axes are rendered. Some options don't take effect until you click outside of the field option box you're editing. You can also press `Enter`.
|
||||
|
||||
| Option | Description |
|
||||
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Time zone | Set the desired time zones to display along the x-axis. |
|
||||
| [Placement](#placement) | Select the placement of the y-axis. |
|
||||
| Label | Set a y-axis text label. If you have more than one y-axis, then you can assign different labels using an override. |
|
||||
| Width | Set a fixed width of the axis. By default, Grafana dynamically calculates the width of an axis. By setting the width of the axis, data with different axes types can share the same display proportions. This setting makes it easier for you to compare more than one graph’s worth of data because the axes aren't shifted or stretched within visual proximity to each other. |
|
||||
| Show grid lines | Set the axis grid line visibility.<br> |
|
||||
| Color | Set the color of the axis. |
|
||||
| Show border | Set the axis border visibility. |
|
||||
| Scale | Set the y-axis values scale.<br> |
|
||||
| Centered zero | Set the y-axis so it's centered on zero. |
|
||||
| [Soft min](#soft-min-and-soft-max) | Set a soft min to better control the y-axis limits. zero. |
|
||||
| [Soft max](#soft-min-and-soft-max) | Set a soft max to better control the y-axis limits. zero. |
|
||||
{{< docs/shared lookup="visualizations/axis-options-all.md" source="grafana" version="<GRAFANA_VERSION>" leveloffset="+1" >}}
|
||||
|
||||
#### Placement
|
||||
|
||||
|
||||
@@ -129,113 +129,111 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
||||
[Experimental](https://grafana.com/docs/release-life-cycle/#experimental) features are early in their development lifecycle and so are not yet supported in Grafana Cloud.
|
||||
Experimental features might be changed or removed without prior notice.
|
||||
|
||||
| Feature toggle name | Description |
|
||||
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `live-service-web-worker` | This will use a webworker thread to processes events rather than the main thread |
|
||||
| `queryOverLive` | Use Grafana Live WebSocket to execute backend queries |
|
||||
| `lokiExperimentalStreaming` | Support new streaming approach for loki (prototype, needs special loki build) |
|
||||
| `storage` | Configurable storage for dashboards, datasources, and resources |
|
||||
| `canvasPanelNesting` | Allow elements nesting |
|
||||
| `vizActions` | Allow actions in visualizations |
|
||||
| `disableSecretsCompatibility` | Disable duplicated secret storage in legacy tables |
|
||||
| `logRequestsInstrumentedAsUnknown` | Logs the path for requests that are instrumented as unknown |
|
||||
| `showDashboardValidationWarnings` | Show warnings when dashboards do not validate against the schema |
|
||||
| `mysqlAnsiQuotes` | Use double quotes to escape keyword in a MySQL query |
|
||||
| `alertingBacktesting` | Rule backtesting API for alerting |
|
||||
| `editPanelCSVDragAndDrop` | Enables drag and drop for CSV and Excel files |
|
||||
| `lokiShardSplitting` | Use stream shards to split queries into smaller subqueries |
|
||||
| `lokiQuerySplittingConfig` | Give users the option to configure split durations for Loki queries |
|
||||
| `individualCookiePreferences` | Support overriding cookie preferences per user |
|
||||
| `influxqlStreamingParser` | Enable streaming JSON parser for InfluxDB datasource InfluxQL query language |
|
||||
| `lokiLogsDataplane` | Changes logs responses from Loki to be compliant with the dataplane specification. |
|
||||
| `disableSSEDataplane` | Disables dataplane specific processing in server side expressions. |
|
||||
| `alertStateHistoryLokiSecondary` | Enable Grafana to write alert state history to an external Loki instance in addition to Grafana annotations. |
|
||||
| `alertStateHistoryLokiPrimary` | Enable a remote Loki instance as the primary source for state history reads. |
|
||||
| `alertStateHistoryLokiOnly` | Disable Grafana alerts from emitting annotations when a remote Loki instance is available. |
|
||||
| `extraThemes` | Enables extra themes |
|
||||
| `lokiPredefinedOperations` | Adds predefined query operations to Loki query editor |
|
||||
| `frontendSandboxMonitorOnly` | Enables monitor only in the plugin frontend sandbox (if enabled) |
|
||||
| `pluginsDetailsRightPanel` | Enables right panel for the plugins details page |
|
||||
| `awsDatasourcesTempCredentials` | Support temporary security credentials in AWS plugins for Grafana Cloud customers |
|
||||
| `mlExpressions` | Enable support for Machine Learning in server-side expressions |
|
||||
| `metricsSummary` | Enables metrics summary queries in the Tempo data source |
|
||||
| `datasourceAPIServers` | Expose some datasources as apiservers. |
|
||||
| `provisioning` | Next generation provisioning... and git |
|
||||
| `permissionsFilterRemoveSubquery` | Alternative permission filter implementation that does not use subqueries for fetching the dashboard folder |
|
||||
| `aiGeneratedDashboardChanges` | Enable AI powered features for dashboards to auto-summary changes when saving |
|
||||
| `sseGroupByDatasource` | Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch. |
|
||||
| `libraryPanelRBAC` | Enables RBAC support for library panels |
|
||||
| `wargamesTesting` | Placeholder feature flag for internal testing |
|
||||
| `externalCorePlugins` | Allow core plugins to be loaded as external |
|
||||
| `pluginsAPIMetrics` | Sends metrics of public grafana packages usage by plugins |
|
||||
| `enableNativeHTTPHistogram` | Enables native HTTP Histograms |
|
||||
| `disableClassicHTTPHistogram` | Disables classic HTTP Histogram (use with enableNativeHTTPHistogram) |
|
||||
| `kubernetesSnapshots` | Routes snapshot requests from /api to the /apis endpoint |
|
||||
| `kubernetesDashboards` | Use the kubernetes API in the frontend for dashboards |
|
||||
| `kubernetesCliDashboards` | Use the k8s client to retrieve dashboards internally |
|
||||
| `kubernetesRestore` | Allow restoring objects in k8s |
|
||||
| `kubernetesFolders` | Use the kubernetes API in the frontend for folders, and route /api/folders requests to k8s |
|
||||
| `kubernetesFoldersServiceV2` | Use the Folders Service V2, and route Folder Service requests to k8s |
|
||||
| `grafanaAPIServerTestingWithExperimentalAPIs` | Facilitate integration testing of experimental APIs |
|
||||
| `datasourceQueryTypes` | Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus) |
|
||||
| `queryService` | Register /apis/query.grafana.app/ -- will eventually replace /api/ds/query |
|
||||
| `queryServiceRewrite` | Rewrite requests targeting /ds/query to the query service |
|
||||
| `queryServiceFromUI` | Routes requests to the new query service |
|
||||
| `cachingOptimizeSerializationMemoryUsage` | If enabled, the caching backend gradually serializes query responses for the cache, comparing against the configured `[caching]max_value_mb` value as it goes. This can can help prevent Grafana from running out of memory while attempting to cache very large query responses. |
|
||||
| `prometheusPromQAIL` | Prometheus and AI/ML to assist users in creating a query |
|
||||
| `prometheusCodeModeMetricNamesSearch` | Enables search for metric names in Code Mode, to improve performance when working with an enormous number of metric names |
|
||||
| `alertmanagerRemoteSecondary` | Enable Grafana to sync configuration and state with a remote Alertmanager. |
|
||||
| `alertmanagerRemotePrimary` | Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager. |
|
||||
| `alertmanagerRemoteOnly` | Disable the internal Alertmanager and only use the external one defined. |
|
||||
| `extractFieldsNameDeduplication` | Make sure extracted field names are unique in the dataframe |
|
||||
| `dashboardNewLayouts` | Enables experimental new dashboard layouts |
|
||||
| `pluginsSkipHostEnvVars` | Disables passing host environment variable to plugin processes |
|
||||
| `tableSharedCrosshair` | Enables shared crosshair in table panel |
|
||||
| `kubernetesFeatureToggles` | Use the kubernetes API for feature toggle management in the frontend |
|
||||
| `newFolderPicker` | Enables the nested folder picker without having nested folders enabled |
|
||||
| `onPremToCloudMigrationsAuthApiMig` | Enables the use of auth api instead of gcom for internal token services. Requires `onPremToCloudMigrations` to be enabled in conjunction. |
|
||||
| `scopeApi` | In-development feature flag for the scope api using the app platform. |
|
||||
| `sqlExpressions` | Enables using SQL and DuckDB functions as Expressions. |
|
||||
| `nodeGraphDotLayout` | Changed the layout algorithm for the node graph |
|
||||
| `kubernetesAggregator` | Enable grafana's embedded kube-aggregator |
|
||||
| `expressionParser` | Enable new expression parser |
|
||||
| `disableNumericMetricsSortingInExpressions` | In server-side expressions, disable the sorting of numeric-kind metrics by their metric name or labels. |
|
||||
| `queryLibrary` | Enables Query Library feature in Explore |
|
||||
| `logsExploreTableDefaultVisualization` | Sets the logs table as default visualisation in logs explore |
|
||||
| `alertingListViewV2` | Enables the new alert list view design |
|
||||
| `dashboardRestore` | Enables deleted dashboard restore feature |
|
||||
| `alertingCentralAlertHistory` | Enables the new central alert history. |
|
||||
| `sqlQuerybuilderFunctionParameters` | Enables SQL query builder function parameters |
|
||||
| `failWrongDSUID` | Throws an error if a datasource has an invalid UIDs |
|
||||
| `dataplaneAggregator` | Enable grafana dataplane aggregator |
|
||||
| `lokiSendDashboardPanelNames` | Send dashboard and panel names to Loki when querying |
|
||||
| `alertingPrometheusRulesPrimary` | Uses Prometheus rules as the primary source of truth for ruler-enabled data sources |
|
||||
| `exploreLogsShardSplitting` | Used in Explore Logs to split queries into multiple queries based on the number of shards |
|
||||
| `exploreLogsAggregatedMetrics` | Used in Explore Logs to query by aggregated metrics |
|
||||
| `exploreLogsLimitedTimeRange` | Used in Explore Logs to limit the time range |
|
||||
| `homeSetupGuide` | Used in Home for users who want to return to the onboarding flow or quickly find popular config pages |
|
||||
| `appSidecar` | Enable the app sidecar feature that allows rendering 2 apps at the same time |
|
||||
| `rolePickerDrawer` | Enables the new role picker drawer design |
|
||||
| `pluginsSriChecks` | Enables SRI checks for plugin assets |
|
||||
| `unifiedStorageBigObjectsSupport` | Enables to save big objects in blob storage |
|
||||
| `timeRangeProvider` | Enables time pickers sync |
|
||||
| `prometheusUsesCombobox` | Use new combobox component for Prometheus query editor |
|
||||
| `playlistsReconciler` | Enables experimental reconciler for playlists |
|
||||
| `prometheusSpecialCharsInLabelValues` | Adds support for quotes and special characters in label values for Prometheus queries |
|
||||
| `enableExtensionsAdminPage` | Enables the extension admin page regardless of development mode |
|
||||
| `enableSCIM` | Enables SCIM support for user and group management |
|
||||
| `crashDetection` | Enables browser crash detection reporting to Faro. |
|
||||
| `jaegerBackendMigration` | Enables querying the Jaeger data source without the proxy |
|
||||
| `useV2DashboardsAPI` | Use the v2 kubernetes API in the frontend for dashboards |
|
||||
| `unifiedHistory` | Displays the navigation history so the user can navigate back to previous pages |
|
||||
| `investigationsBackend` | Enable the investigations backend API |
|
||||
| `k8SFolderCounts` | Enable folder's api server counts |
|
||||
| `k8SFolderMove` | Enable folder's api server move |
|
||||
| `teamHttpHeadersMimir` | Enables LBAC for datasources for Mimir to apply LBAC filtering of metrics to the client requests for users in teams |
|
||||
| `queryLibraryDashboards` | Enables Query Library feature in Dashboards |
|
||||
| `grafanaAdvisor` | Enables Advisor app |
|
||||
| `elasticsearchImprovedParsing` | Enables less memory intensive Elasticsearch result parsing |
|
||||
| `datasourceConnectionsTab` | Shows defined connections for a data source in the plugins detail page |
|
||||
| Feature toggle name | Description |
|
||||
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `live-service-web-worker` | This will use a webworker thread to processes events rather than the main thread |
|
||||
| `queryOverLive` | Use Grafana Live WebSocket to execute backend queries |
|
||||
| `lokiExperimentalStreaming` | Support new streaming approach for loki (prototype, needs special loki build) |
|
||||
| `storage` | Configurable storage for dashboards, datasources, and resources |
|
||||
| `canvasPanelNesting` | Allow elements nesting |
|
||||
| `vizActions` | Allow actions in visualizations |
|
||||
| `disableSecretsCompatibility` | Disable duplicated secret storage in legacy tables |
|
||||
| `logRequestsInstrumentedAsUnknown` | Logs the path for requests that are instrumented as unknown |
|
||||
| `showDashboardValidationWarnings` | Show warnings when dashboards do not validate against the schema |
|
||||
| `mysqlAnsiQuotes` | Use double quotes to escape keyword in a MySQL query |
|
||||
| `alertingBacktesting` | Rule backtesting API for alerting |
|
||||
| `editPanelCSVDragAndDrop` | Enables drag and drop for CSV and Excel files |
|
||||
| `lokiShardSplitting` | Use stream shards to split queries into smaller subqueries |
|
||||
| `lokiQuerySplittingConfig` | Give users the option to configure split durations for Loki queries |
|
||||
| `individualCookiePreferences` | Support overriding cookie preferences per user |
|
||||
| `influxqlStreamingParser` | Enable streaming JSON parser for InfluxDB datasource InfluxQL query language |
|
||||
| `lokiLogsDataplane` | Changes logs responses from Loki to be compliant with the dataplane specification. |
|
||||
| `disableSSEDataplane` | Disables dataplane specific processing in server side expressions. |
|
||||
| `alertStateHistoryLokiSecondary` | Enable Grafana to write alert state history to an external Loki instance in addition to Grafana annotations. |
|
||||
| `alertStateHistoryLokiPrimary` | Enable a remote Loki instance as the primary source for state history reads. |
|
||||
| `alertStateHistoryLokiOnly` | Disable Grafana alerts from emitting annotations when a remote Loki instance is available. |
|
||||
| `extraThemes` | Enables extra themes |
|
||||
| `lokiPredefinedOperations` | Adds predefined query operations to Loki query editor |
|
||||
| `frontendSandboxMonitorOnly` | Enables monitor only in the plugin frontend sandbox (if enabled) |
|
||||
| `pluginsDetailsRightPanel` | Enables right panel for the plugins details page |
|
||||
| `awsDatasourcesTempCredentials` | Support temporary security credentials in AWS plugins for Grafana Cloud customers |
|
||||
| `mlExpressions` | Enable support for Machine Learning in server-side expressions |
|
||||
| `metricsSummary` | Enables metrics summary queries in the Tempo data source |
|
||||
| `datasourceAPIServers` | Expose some datasources as apiservers. |
|
||||
| `provisioning` | Next generation provisioning... and git |
|
||||
| `permissionsFilterRemoveSubquery` | Alternative permission filter implementation that does not use subqueries for fetching the dashboard folder |
|
||||
| `aiGeneratedDashboardChanges` | Enable AI powered features for dashboards to auto-summary changes when saving |
|
||||
| `sseGroupByDatasource` | Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch. |
|
||||
| `libraryPanelRBAC` | Enables RBAC support for library panels |
|
||||
| `wargamesTesting` | Placeholder feature flag for internal testing |
|
||||
| `externalCorePlugins` | Allow core plugins to be loaded as external |
|
||||
| `pluginsAPIMetrics` | Sends metrics of public grafana packages usage by plugins |
|
||||
| `enableNativeHTTPHistogram` | Enables native HTTP Histograms |
|
||||
| `disableClassicHTTPHistogram` | Disables classic HTTP Histogram (use with enableNativeHTTPHistogram) |
|
||||
| `kubernetesSnapshots` | Routes snapshot requests from /api to the /apis endpoint |
|
||||
| `kubernetesDashboards` | Use the kubernetes API in the frontend for dashboards |
|
||||
| `kubernetesCliDashboards` | Use the k8s client to retrieve dashboards internally |
|
||||
| `kubernetesRestore` | Allow restoring objects in k8s |
|
||||
| `kubernetesFoldersServiceV2` | Use the Folders Service V2, and route Folder Service requests to k8s |
|
||||
| `datasourceQueryTypes` | Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus) |
|
||||
| `queryService` | Register /apis/query.grafana.app/ -- will eventually replace /api/ds/query |
|
||||
| `queryServiceRewrite` | Rewrite requests targeting /ds/query to the query service |
|
||||
| `queryServiceFromUI` | Routes requests to the new query service |
|
||||
| `cachingOptimizeSerializationMemoryUsage` | If enabled, the caching backend gradually serializes query responses for the cache, comparing against the configured `[caching]max_value_mb` value as it goes. This can can help prevent Grafana from running out of memory while attempting to cache very large query responses. |
|
||||
| `prometheusPromQAIL` | Prometheus and AI/ML to assist users in creating a query |
|
||||
| `prometheusCodeModeMetricNamesSearch` | Enables search for metric names in Code Mode, to improve performance when working with an enormous number of metric names |
|
||||
| `alertmanagerRemoteSecondary` | Enable Grafana to sync configuration and state with a remote Alertmanager. |
|
||||
| `alertmanagerRemotePrimary` | Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager. |
|
||||
| `alertmanagerRemoteOnly` | Disable the internal Alertmanager and only use the external one defined. |
|
||||
| `extractFieldsNameDeduplication` | Make sure extracted field names are unique in the dataframe |
|
||||
| `dashboardNewLayouts` | Enables experimental new dashboard layouts |
|
||||
| `pluginsSkipHostEnvVars` | Disables passing host environment variable to plugin processes |
|
||||
| `tableSharedCrosshair` | Enables shared crosshair in table panel |
|
||||
| `kubernetesFeatureToggles` | Use the kubernetes API for feature toggle management in the frontend |
|
||||
| `newFolderPicker` | Enables the nested folder picker without having nested folders enabled |
|
||||
| `onPremToCloudMigrationsAuthApiMig` | Enables the use of auth api instead of gcom for internal token services. Requires `onPremToCloudMigrations` to be enabled in conjunction. |
|
||||
| `scopeApi` | In-development feature flag for the scope api using the app platform. |
|
||||
| `sqlExpressions` | Enables using SQL and DuckDB functions as Expressions. |
|
||||
| `nodeGraphDotLayout` | Changed the layout algorithm for the node graph |
|
||||
| `kubernetesAggregator` | Enable grafana's embedded kube-aggregator |
|
||||
| `expressionParser` | Enable new expression parser |
|
||||
| `disableNumericMetricsSortingInExpressions` | In server-side expressions, disable the sorting of numeric-kind metrics by their metric name or labels. |
|
||||
| `queryLibrary` | Enables Query Library feature in Explore |
|
||||
| `logsExploreTableDefaultVisualization` | Sets the logs table as default visualisation in logs explore |
|
||||
| `alertingListViewV2` | Enables the new alert list view design |
|
||||
| `dashboardRestore` | Enables deleted dashboard restore feature |
|
||||
| `alertingCentralAlertHistory` | Enables the new central alert history. |
|
||||
| `sqlQuerybuilderFunctionParameters` | Enables SQL query builder function parameters |
|
||||
| `failWrongDSUID` | Throws an error if a datasource has an invalid UIDs |
|
||||
| `dataplaneAggregator` | Enable grafana dataplane aggregator |
|
||||
| `lokiSendDashboardPanelNames` | Send dashboard and panel names to Loki when querying |
|
||||
| `alertingPrometheusRulesPrimary` | Uses Prometheus rules as the primary source of truth for ruler-enabled data sources |
|
||||
| `exploreLogsShardSplitting` | Used in Explore Logs to split queries into multiple queries based on the number of shards |
|
||||
| `exploreLogsAggregatedMetrics` | Used in Explore Logs to query by aggregated metrics |
|
||||
| `exploreLogsLimitedTimeRange` | Used in Explore Logs to limit the time range |
|
||||
| `homeSetupGuide` | Used in Home for users who want to return to the onboarding flow or quickly find popular config pages |
|
||||
| `appSidecar` | Enable the app sidecar feature that allows rendering 2 apps at the same time |
|
||||
| `rolePickerDrawer` | Enables the new role picker drawer design |
|
||||
| `pluginsSriChecks` | Enables SRI checks for plugin assets |
|
||||
| `unifiedStorageBigObjectsSupport` | Enables to save big objects in blob storage |
|
||||
| `timeRangeProvider` | Enables time pickers sync |
|
||||
| `prometheusUsesCombobox` | Use new combobox component for Prometheus query editor |
|
||||
| `playlistsReconciler` | Enables experimental reconciler for playlists |
|
||||
| `prometheusSpecialCharsInLabelValues` | Adds support for quotes and special characters in label values for Prometheus queries |
|
||||
| `enableExtensionsAdminPage` | Enables the extension admin page regardless of development mode |
|
||||
| `enableSCIM` | Enables SCIM support for user and group management |
|
||||
| `crashDetection` | Enables browser crash detection reporting to Faro. |
|
||||
| `jaegerBackendMigration` | Enables querying the Jaeger data source without the proxy |
|
||||
| `useV2DashboardsAPI` | Use the v2 kubernetes API in the frontend for dashboards |
|
||||
| `unifiedHistory` | Displays the navigation history so the user can navigate back to previous pages |
|
||||
| `investigationsBackend` | Enable the investigations backend API |
|
||||
| `k8SFolderCounts` | Enable folder's api server counts |
|
||||
| `k8SFolderMove` | Enable folder's api server move |
|
||||
| `teamHttpHeadersMimir` | Enables LBAC for datasources for Mimir to apply LBAC filtering of metrics to the client requests for users in teams |
|
||||
| `queryLibraryDashboards` | Enables Query Library feature in Dashboards |
|
||||
| `grafanaAdvisor` | Enables Advisor app |
|
||||
| `elasticsearchImprovedParsing` | Enables less memory intensive Elasticsearch result parsing |
|
||||
| `datasourceConnectionsTab` | Shows defined connections for a data source in the plugins detail page |
|
||||
|
||||
## Development feature toggles
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: Axis options
|
||||
comments: |
|
||||
This file is used in the following visualizations: time series.
|
||||
---
|
||||
|
||||
Options under the **Axis** section control how the x- and y-axes are rendered. Some options don't take effect until you click outside of the field option box you're editing. You can also press `Enter`.
|
||||
|
||||
| Option | Description |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| Time zone | Set the desired time zones to display along the x-axis. |
|
||||
| [Placement](#placement) | Select the placement of the y-axis. |
|
||||
| Label | Set a y-axis text label. If you have more than one y-axis, then you can assign different labels using an override. |
|
||||
| Width | Set a fixed width for the axis. By default, Grafana dynamically calculates the width of an axis. |
|
||||
| Show grid lines | Set the axis grid line visibility.<br> |
|
||||
| Color | Set the color of the axis. |
|
||||
| Show border | Set the axis border visibility. |
|
||||
| Scale | Set the y-axis values scale.<br> |
|
||||
| Centered zero | Set the y-axis so it's centered on zero. |
|
||||
| [Soft min](#soft-min-and-soft-max) | Set a soft min to better control the y-axis limits. zero. |
|
||||
| [Soft max](#soft-min-and-soft-max) | Set a soft max to better control the y-axis limits. zero. |
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Axis options
|
||||
comments: |
|
||||
This file is used in the following visualizations: state timeline, status history.
|
||||
---
|
||||
|
||||
| Option | Description |
|
||||
| --------- | ------------------------------------------------------------------------------------------------ |
|
||||
| Placement | Control the visibility of series names along the y-axis or time values along the x-axis. |
|
||||
| Width | Set a fixed width for the axis. By default, Grafana dynamically calculates the width of an axis. |
|
||||
@@ -71,8 +71,7 @@ require (
|
||||
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
|
||||
github.com/gorilla/websocket v1.5.3 // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/alerting v0.0.0-20250117230852-a5e8136407d4 // @grafana/alerting-backend
|
||||
github.com/grafana/authlib v0.0.0-20250120145936-5f0e28e7a87c // @grafana/identity-access-team
|
||||
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 // @grafana/identity-access-team
|
||||
github.com/grafana/authlib v0.0.0-20250123104008-e99947858901 // @grafana/identity-access-team
|
||||
github.com/grafana/authlib/types v0.0.0-20250120145936-5f0e28e7a87c // @grafana/identity-access-team
|
||||
github.com/grafana/dashboard-linter v0.0.0-20241106223805-1e7999311752 // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
|
||||
@@ -201,7 +200,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/grafana/grafana/apps/advisor v0.0.0-20250121115006-c1eac9f9973f // @grafana/plugins-platform-backend
|
||||
github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173 // @grafana/plugins-platform-backend
|
||||
github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250121113133-e747350fee2d // @grafana/alerting-backend
|
||||
github.com/grafana/grafana/apps/investigation v0.0.0-20250121113133-e747350fee2d // @fcjack @matryer
|
||||
github.com/grafana/grafana/apps/playlist v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-app-platform-squad
|
||||
@@ -212,7 +211,7 @@ require (
|
||||
// This needs to be here for other projects that import grafana/grafana
|
||||
// For local development grafana/grafana will always use the local files
|
||||
// Check go.work file for details
|
||||
github.com/grafana/grafana/pkg/promlib v0.0.7 // @grafana/oss-big-tent
|
||||
github.com/grafana/grafana/pkg/promlib v0.0.8 // @grafana/oss-big-tent
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-search-and-storage
|
||||
github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-search-and-storage
|
||||
|
||||
@@ -1502,10 +1502,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/alerting v0.0.0-20250117230852-a5e8136407d4 h1:616HUg7WVyLJLtVvk2pZ845M4Gk0fswgTwEZSuAZbJU=
|
||||
github.com/grafana/alerting v0.0.0-20250117230852-a5e8136407d4/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
|
||||
github.com/grafana/authlib v0.0.0-20250120145936-5f0e28e7a87c h1:duHQ8Bih3nt9p68aQdp+22a6mFBLpK4IOURhEq+Cvk8=
|
||||
github.com/grafana/authlib v0.0.0-20250120145936-5f0e28e7a87c/go.mod h1:/gYfphsNu9v1qYWXxpv1NSvMEMSwvdf8qb8YlgwIRl8=
|
||||
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk=
|
||||
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A=
|
||||
github.com/grafana/authlib v0.0.0-20250123104008-e99947858901 h1:nqV1YrtX+ZG+EYB5dcmFMWhg2Y038OMaAHAADbOC9RA=
|
||||
github.com/grafana/authlib v0.0.0-20250123104008-e99947858901/go.mod h1:/gYfphsNu9v1qYWXxpv1NSvMEMSwvdf8qb8YlgwIRl8=
|
||||
github.com/grafana/authlib/types v0.0.0-20250120145936-5f0e28e7a87c h1:b0sPDtt33uFdmvUJjSCld3kwE2E49dUvevuUDSJsEuo=
|
||||
github.com/grafana/authlib/types v0.0.0-20250120145936-5f0e28e7a87c/go.mod h1:qYjSd1tmJiuVoSICp7Py9/zD54O9uQQA3wuM6Gg4DFM=
|
||||
github.com/grafana/dashboard-linter v0.0.0-20241106223805-1e7999311752 h1:pIEPQPua6kCkMib/nOn//WLgtiA4urf2RPBViSF6CcE=
|
||||
@@ -1540,8 +1538,8 @@ github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79
|
||||
github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/go.mod h1:wc6Hbh3K2TgCUSfBC/BOzabItujtHMESZeFk5ZhdxhQ=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.262.0 h1:R2DV6lwBQE5zaogxX3PorD9Seo8CXA8YuStf84oqwkk=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.262.0/go.mod h1:U43Cnrj/9DNYyvFcNdeUWNjMXTKNB0jcTcQGpWKd2gw=
|
||||
github.com/grafana/grafana/apps/advisor v0.0.0-20250121115006-c1eac9f9973f h1:c8IkbxPvM6+lscVOLgtbt8Gnro4Liltd+E2eqkoAeZA=
|
||||
github.com/grafana/grafana/apps/advisor v0.0.0-20250121115006-c1eac9f9973f/go.mod h1:goSDiy3jtC2cp8wjpPZdUHRENcoSUHae1/Px/MDfddA=
|
||||
github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173 h1:uOM89HiWVVOTls0LrD4coHTckb2lA4U0sIJwCYdbhbw=
|
||||
github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173/go.mod h1:goSDiy3jtC2cp8wjpPZdUHRENcoSUHae1/Px/MDfddA=
|
||||
github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250121113133-e747350fee2d h1:NRVOtiG1aUwOazBj9KM7X2o2shsM6TchqisezzoH1gw=
|
||||
github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250121113133-e747350fee2d/go.mod h1:AvleS6icyPmcBjihtx5jYEvdzLmHGBp66NuE0AMR57A=
|
||||
github.com/grafana/grafana/apps/investigation v0.0.0-20250121113133-e747350fee2d h1:oNc/aDfDucQxLbRZK25yz3Cwc+dGo1C0Xmm2LaliWUQ=
|
||||
@@ -1554,8 +1552,8 @@ github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d h
|
||||
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d/go.mod h1:PNmbi49lVrv2b2I8pdu46dTs2728lKEbnVuQD8I5MnM=
|
||||
github.com/grafana/grafana/pkg/apiserver v0.0.0-20250121113133-e747350fee2d h1:vVaLOibSj5lDwCb+pa0fly9uods8nTq1NJPYrTuPAGE=
|
||||
github.com/grafana/grafana/pkg/apiserver v0.0.0-20250121113133-e747350fee2d/go.mod h1:srJ6H0DL7c9Cz4CmK0h4AOlC1sm8UDwn9jBZ+RHxBt4=
|
||||
github.com/grafana/grafana/pkg/promlib v0.0.7 h1:BdpanKOKnID/l1BJZLhE7TRNtmq7aOVdou1LBFWaMmU=
|
||||
github.com/grafana/grafana/pkg/promlib v0.0.7/go.mod h1:rnwJXCA2xRwb7F27NB35iO/JsLL/H/+eVXECk/hrEhQ=
|
||||
github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0=
|
||||
github.com/grafana/grafana/pkg/promlib v0.0.8/go.mod h1:U1ezG/MGaEPoThqsr3lymMPN5yIPdVTJnDZ+wcXT+ao=
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d h1:cYBjuhb3m5oC6Z00Kw8DdySFaNhwb38SMxx0oXnz5vQ=
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d/go.mod h1:tfLnBpPYgwrBMRz4EXqPCZJyCjEG4Ev37FSlXnocJ2c=
|
||||
github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250121113133-e747350fee2d h1:4w4x8g6xCSRj06er81atNSQVL0fTSwLekDYsRxpz+M0=
|
||||
|
||||
+6
-2
@@ -1455,6 +1455,8 @@ github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9
|
||||
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/cel-go v0.22.0/go.mod h1:BuznPXXfQDpXKWQ9sPW3TzlAJN5zzFe+i9tIs0yC4s8=
|
||||
github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-github/v66 v66.0.0 h1:ADJsaXj9UotwdgK8/iFZtv7MLc8E8WBl62WLd/D/9+M=
|
||||
github.com/google/go-github/v66 v66.0.0/go.mod h1:+4SO9Zkuyf8ytMj0csN1NR/5OTR+MfqPp8P8dVlcvY4=
|
||||
github.com/google/go-jsonnet v0.18.0 h1:/6pTy6g+Jh1a1I2UMoAODkqELFiVIdOxbNwv0DDzoOg=
|
||||
github.com/google/go-jsonnet v0.18.0/go.mod h1:C3fTzyVJDslXdiTqw/bTFk7vSGyCtH3MGRbDfvEwGd0=
|
||||
github.com/google/go-pkcs11 v0.2.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
|
||||
@@ -1497,17 +1499,19 @@ github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH
|
||||
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/alerting v0.0.0-20250117230852-a5e8136407d4 h1:616HUg7WVyLJLtVvk2pZ845M4Gk0fswgTwEZSuAZbJU=
|
||||
github.com/grafana/alerting v0.0.0-20250117230852-a5e8136407d4/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
|
||||
github.com/grafana/authlib v0.0.0-20250120144156-d6737a7dc8f5/go.mod h1:V63rh3udd7sqXJeaG+nGUmViwVnM/bY6t8U9Tols2GU=
|
||||
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk=
|
||||
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A=
|
||||
github.com/grafana/authlib/types v0.0.0-20250120144156-d6737a7dc8f5/go.mod h1:qYjSd1tmJiuVoSICp7Py9/zD54O9uQQA3wuM6Gg4DFM=
|
||||
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2 h1:qhugDMdQ4Vp68H0tp/0iN17DM2ehRo1rLEdOFe/gB8I=
|
||||
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2/go.mod h1:w/aiO1POVIeXUQyl0VQSZjl5OAGDTL5aX+4v0RA1tcw=
|
||||
github.com/grafana/dashboard-linter v0.0.0-20241106223805-1e7999311752 h1:pIEPQPua6kCkMib/nOn//WLgtiA4urf2RPBViSF6CcE=
|
||||
github.com/grafana/dashboard-linter v0.0.0-20241106223805-1e7999311752/go.mod h1:QRxC8NNAbpGx2vz9/XckJcWXtHU3My57byeudPrf0WU=
|
||||
github.com/grafana/go-gelf/v2 v2.0.1 h1:BOChP0h/jLeD+7F9mL7tq10xVkDG15he3T1zHuQaWak=
|
||||
github.com/grafana/go-gelf/v2 v2.0.1/go.mod h1:lexHie0xzYGwCgiRGcvZ723bSNyNI8ZRD4s0CLobh90=
|
||||
github.com/grafana/grafana-app-sdk v0.29.0/go.mod h1:XLt308EmK6kvqPlzjUyXxbwZKEk2vur/eiypUNDay5I=
|
||||
github.com/grafana/grafana-foundation-sdk/go v0.0.0-20241101005901-83e3491f2a70 h1:69GI3KsF851YnwYp6zHdsskcGp3ZnGsWc+ve8vMp1mc=
|
||||
github.com/grafana/grafana-foundation-sdk/go v0.0.0-20241101005901-83e3491f2a70/go.mod h1:WtWosval1KCZP9BGa42b8aVoJmVXSg0EvQXi9LDSVZQ=
|
||||
github.com/grafana/regexp v0.0.0-20221122212121-6b5c0a4cb7fd/go.mod h1:M5qHK+eWfAv8VR/265dIuEpL3fNfeC21tXXp9itM24A=
|
||||
github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0 h1:bjh0PVYSVVFxzINqPFYJmAmJNrWPgnVjuSdYJGHmtFU=
|
||||
github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0/go.mod h1:7t5XR+2IA8P2qggOAHTj/GCZfoLBle3OvNSYh1VkRBU=
|
||||
|
||||
+4
-4
@@ -81,7 +81,7 @@
|
||||
"@grafana/plugin-e2e": "^1.11.0",
|
||||
"@grafana/tsconfig": "^2.0.0",
|
||||
"@manypkg/get-packages": "^2.2.0",
|
||||
"@playwright/test": "1.49.1",
|
||||
"@playwright/test": "1.50.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "0.5.15",
|
||||
"@react-types/button": "3.10.2",
|
||||
"@react-types/menu": "3.9.14",
|
||||
@@ -123,7 +123,7 @@
|
||||
"@types/lodash": "4.17.14",
|
||||
"@types/logfmt": "^1.2.3",
|
||||
"@types/lucene": "^2",
|
||||
"@types/node": "22.10.7",
|
||||
"@types/node": "22.10.9",
|
||||
"@types/node-forge": "^1",
|
||||
"@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.2.4",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
@@ -228,7 +228,7 @@
|
||||
"sass-loader": "16.0.4",
|
||||
"smtp-tester": "^2.1.0",
|
||||
"style-loader": "4.0.0",
|
||||
"stylelint": "16.12.0",
|
||||
"stylelint": "16.13.2",
|
||||
"stylelint-config-sass-guidelines": "12.1.0",
|
||||
"terser-webpack-plugin": "5.3.11",
|
||||
"testing-library-selector": "0.3.1",
|
||||
@@ -367,7 +367,7 @@
|
||||
"react-draggable": "4.4.6",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-grid-layout": "patch:react-grid-layout@npm%3A1.4.4#~/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch",
|
||||
"react-highlight-words": "0.20.0",
|
||||
"react-highlight-words": "0.21.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-i18next": "^15.0.0",
|
||||
"react-inlinesvg": "4.1.5",
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
"@rollup/plugin-node-resolve": "16.0.0",
|
||||
"@types/history": "4.7.11",
|
||||
"@types/lodash": "4.17.14",
|
||||
"@types/node": "22.10.7",
|
||||
"@types/node": "22.10.9",
|
||||
"@types/papaparse": "5.3.15",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
|
||||
@@ -501,6 +501,7 @@ export const getLinksSupplier =
|
||||
});
|
||||
},
|
||||
origin: field,
|
||||
oneClick: link.oneClick ?? false,
|
||||
};
|
||||
} else {
|
||||
linkModel = {
|
||||
@@ -508,6 +509,7 @@ export const getLinksSupplier =
|
||||
title: replaceVariables(link.title || '', dataLinkScopedVars),
|
||||
target: link.targetBlank ? '_blank' : undefined,
|
||||
origin: field,
|
||||
oneClick: link.oneClick ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface DataLink<T extends DataQuery = any> {
|
||||
correlationData?: ExploreCorrelationHelperData;
|
||||
transformations?: DataLinkTransformationConfig[];
|
||||
};
|
||||
|
||||
oneClick?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,6 +100,7 @@ export interface LinkModel<T = any> {
|
||||
|
||||
// When a click callback exists, this is passed the raw mouse|react event
|
||||
onClick?: (e: any, origin?: any) => void;
|
||||
oneClick?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -112,9 +112,7 @@ export interface FeatureToggles {
|
||||
kubernetesDashboards?: boolean;
|
||||
kubernetesCliDashboards?: boolean;
|
||||
kubernetesRestore?: boolean;
|
||||
kubernetesFolders?: boolean;
|
||||
kubernetesFoldersServiceV2?: boolean;
|
||||
grafanaAPIServerTestingWithExperimentalAPIs?: boolean;
|
||||
datasourceQueryTypes?: boolean;
|
||||
queryService?: boolean;
|
||||
queryServiceRewrite?: boolean;
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-node-resolve": "16.0.0",
|
||||
"@types/node": "22.10.7",
|
||||
"@types/node": "22.10.9",
|
||||
"@types/semver": "7.5.8",
|
||||
"esbuild": "0.24.2",
|
||||
"rimraf": "6.0.1",
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
"@types/d3": "^7",
|
||||
"@types/jest": "^29.5.4",
|
||||
"@types/lodash": "4.17.14",
|
||||
"@types/node": "22.10.7",
|
||||
"@types/node": "22.10.9",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-virtualized-auto-sizer": "1.0.4",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"@svgr/plugin-prettier": "^8.1.0",
|
||||
"@svgr/plugin-svgo": "^8.1.0",
|
||||
"@types/babel__core": "^7",
|
||||
"@types/node": "22.10.7",
|
||||
"@types/node": "22.10.9",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"esbuild": "0.24.2",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"@testing-library/react": "16.1.0",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/jest": "^29.5.4",
|
||||
"@types/node": "22.10.7",
|
||||
"@types/node": "22.10.9",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/systemjs": "6.15.1",
|
||||
"jest": "^29.6.4",
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
"monaco-promql": "1.7.4",
|
||||
"pluralize": "8.0.0",
|
||||
"prismjs": "1.29.0",
|
||||
"react-highlight-words": "0.20.0",
|
||||
"react-highlight-words": "0.21.0",
|
||||
"react-select": "5.9.0",
|
||||
"react-use": "17.6.0",
|
||||
"react-window": "1.8.11",
|
||||
@@ -92,7 +92,7 @@
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/jquery": "3.5.32",
|
||||
"@types/lodash": "4.17.14",
|
||||
"@types/node": "22.10.7",
|
||||
"@types/node": "22.10.9",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/prismjs": "1.26.5",
|
||||
"@types/react": "18.3.18",
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ export const defaultOptions: Partial<Options> = {
|
||||
showValue: ui.VisibilityMode.Auto,
|
||||
};
|
||||
|
||||
export interface FieldConfig extends ui.HideableFieldConfig {
|
||||
export interface FieldConfig extends ui.AxisConfig, ui.HideableFieldConfig {
|
||||
fillOpacity?: number;
|
||||
lineWidth?: number;
|
||||
}
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ export const defaultOptions: Partial<Options> = {
|
||||
showValue: ui.VisibilityMode.Auto,
|
||||
};
|
||||
|
||||
export interface FieldConfig extends ui.HideableFieldConfig {
|
||||
export interface FieldConfig extends ui.AxisConfig, ui.HideableFieldConfig {
|
||||
fillOpacity?: number;
|
||||
lineWidth?: number;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/jest": "^29.5.4",
|
||||
"@types/lodash": "4.17.14",
|
||||
"@types/node": "22.10.7",
|
||||
"@types/node": "22.10.9",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/react-virtualized-auto-sizer": "1.0.4",
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
"react-colorful": "5.6.1",
|
||||
"react-custom-scrollbars-2": "4.5.0",
|
||||
"react-dropzone": "14.3.5",
|
||||
"react-highlight-words": "0.20.0",
|
||||
"react-highlight-words": "0.21.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-i18next": "^15.0.0",
|
||||
"react-inlinesvg": "4.1.5",
|
||||
@@ -145,7 +145,7 @@
|
||||
"@types/is-hotkey": "0.1.10",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/mock-raf": "1.0.6",
|
||||
"@types/node": "22.10.7",
|
||||
"@types/node": "22.10.9",
|
||||
"@types/prismjs": "1.26.5",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-color": "3.0.13",
|
||||
|
||||
@@ -74,11 +74,6 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
|
||||
return newItems;
|
||||
}, [baseItems, inputValue, enableAllOption, allOptionItem]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { inputRef: containerRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, isOpen);
|
||||
|
||||
const multiStyles = useStyles2(getMultiComboboxStyles, isOpen, invalid, disabled);
|
||||
|
||||
const { measureRef, counterMeasureRef, suffixMeasureRef, shownItems } = useMeasureMulti(
|
||||
selectedItems,
|
||||
@@ -108,17 +103,41 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
break;
|
||||
}
|
||||
},
|
||||
stateReducer: (state, actionAndChanges) => {
|
||||
const { changes } = actionAndChanges;
|
||||
return {
|
||||
...changes,
|
||||
|
||||
/**
|
||||
* TODO: Fix Hack!
|
||||
* This prevents the menu from closing when the user unselects an item in the dropdown at the expense
|
||||
* of breaking keyboard navigation.
|
||||
*
|
||||
* Downshift isn't really designed to keep selected items in the dropdown menu, so when you unselect an item
|
||||
* in a multiselect, the stateReducer tries to move focus onto another item which causes the menu to be closed.
|
||||
* This only seems to happen when you deselect the last item in the selectedItems list.
|
||||
*
|
||||
* Check out:
|
||||
* - FunctionRemoveSelectedItem in the useMultipleSelection reducer https://github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/reducer.js#L75
|
||||
* - The activeIndex useEffect in useMultipleSelection https://github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/index.js#L68-L72
|
||||
*
|
||||
* Forcing the activeIndex to -999 both prevents the useEffect that changes the focus from triggering (value never changes)
|
||||
* and prevents the if statement in useMultipleSelection from focusing anything.
|
||||
*/
|
||||
activeIndex: -999,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
//getToggleButtonProps,
|
||||
//getLabelProps,
|
||||
isOpen,
|
||||
highlightedIndex,
|
||||
getMenuProps,
|
||||
getInputProps,
|
||||
highlightedIndex,
|
||||
getItemProps,
|
||||
} = useCombobox({
|
||||
isOpen,
|
||||
items,
|
||||
itemToString,
|
||||
inputValue,
|
||||
@@ -135,7 +154,6 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
};
|
||||
case useCombobox.stateChangeTypes.InputBlur:
|
||||
setInputValue('');
|
||||
setIsOpen(false);
|
||||
default:
|
||||
return changes;
|
||||
}
|
||||
@@ -175,14 +193,15 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
case useCombobox.stateChangeTypes.InputChange:
|
||||
setInputValue(newInputValue ?? '');
|
||||
break;
|
||||
case useCombobox.stateChangeTypes.InputClick:
|
||||
setIsOpen(true);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { inputRef: containerRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, isOpen);
|
||||
const multiStyles = useStyles2(getMultiComboboxStyles, isOpen, invalid, disabled);
|
||||
|
||||
const virtualizerOptions = {
|
||||
count: items.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
@@ -241,7 +260,6 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
disabled,
|
||||
preventKeyAction: isOpen,
|
||||
placeholder: selectedItems.length > 0 ? undefined : placeholder,
|
||||
onFocus: () => !disabled && setIsOpen(true),
|
||||
})
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { VariableSuggestion, GrafanaTheme2, DataLink } from '@grafana/data';
|
||||
|
||||
import { useStyles2 } from '../../themes/index';
|
||||
import { isCompactUrl } from '../../utils/dataLinks';
|
||||
import { Trans } from '../../utils/i18n';
|
||||
import { t, Trans } from '../../utils/i18n';
|
||||
import { Field } from '../Forms/Field';
|
||||
import { Input } from '../Input/Input';
|
||||
import { Switch } from '../Switch/Switch';
|
||||
@@ -45,6 +45,10 @@ export const DataLinkEditor = memo(({ index, value, onChange, suggestions, isLas
|
||||
onChange(index, { ...value, targetBlank: !value.targetBlank });
|
||||
};
|
||||
|
||||
const onOneClickChanged = () => {
|
||||
onChange(index, { ...value, oneClick: !value.oneClick });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.listItem}>
|
||||
<Field label="Title">
|
||||
@@ -63,6 +67,16 @@ export const DataLinkEditor = memo(({ index, value, onChange, suggestions, isLas
|
||||
<Switch value={value.targetBlank || false} onChange={onOpenInNewTabChanged} />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t('grafana-ui.data-link-inline-editor.one-click', 'One click')}
|
||||
description={t(
|
||||
'grafana-ui.data-link-editor-modal.one-click-description',
|
||||
'Only one link can have one click enabled at a time'
|
||||
)}
|
||||
>
|
||||
<Switch value={value.oneClick || false} onChange={onOneClickChanged} />
|
||||
</Field>
|
||||
|
||||
{isLast && (
|
||||
<div className={styles.infoText}>
|
||||
<Trans i18nKey="grafana-ui.data-link-editor.info">
|
||||
|
||||
+9
@@ -51,6 +51,15 @@ export const DataLinksInlineEditor = ({
|
||||
setIsNew(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (link.oneClick === true) {
|
||||
linksSafe.forEach((link) => {
|
||||
if (link.oneClick) {
|
||||
link.oneClick = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const update = cloneDeep(linksSafe);
|
||||
update[index] = link;
|
||||
onChange(update);
|
||||
|
||||
+10
-1
@@ -5,6 +5,8 @@ import { DataFrame, DataLink, GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { useStyles2 } from '../../../themes';
|
||||
import { isCompactUrl } from '../../../utils';
|
||||
import { t } from '../../../utils/i18n';
|
||||
import { Badge } from '../../Badge/Badge';
|
||||
import { Icon } from '../../Icon/Icon';
|
||||
import { IconButton } from '../../IconButton/IconButton';
|
||||
import { Tooltip } from '../../Tooltip/Tooltip';
|
||||
@@ -22,7 +24,7 @@ export interface DataLinksListItemProps {
|
||||
|
||||
export const DataLinksListItem = ({ link, onEdit, onRemove, index, itemKey }: DataLinksListItemProps) => {
|
||||
const styles = useStyles2(getDataLinkListItemStyles);
|
||||
const { title = '', url = '' } = link;
|
||||
const { title = '', url = '', oneClick = false } = link;
|
||||
|
||||
const hasTitle = title.trim() !== '';
|
||||
const hasUrl = url.trim() !== '';
|
||||
@@ -52,6 +54,13 @@ export const DataLinksListItem = ({ link, onEdit, onRemove, index, itemKey }: Da
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.icons}>
|
||||
{oneClick && (
|
||||
<Badge
|
||||
color="blue"
|
||||
text={t('grafana-ui.data-links-inline-editor.one-click', 'One click')}
|
||||
tooltip={t('grafana-ui.data-links-inline-editor.one-click-enabled', 'One click enabled')}
|
||||
/>
|
||||
)}
|
||||
<IconButton name="pen" onClick={onEdit} className={styles.icon} tooltip="Edit data link" />
|
||||
<IconButton name="trash-alt" onClick={onRemove} className={styles.icon} tooltip="Remove data link" />
|
||||
<div className={styles.dragIcon} {...provided.dragHandleProps}>
|
||||
|
||||
@@ -64,12 +64,17 @@ export const Input = forwardRef<HTMLInputElement, Props>((props, ref) => {
|
||||
|
||||
const theme = useTheme2();
|
||||
|
||||
const styles = getInputStyles({ theme, invalid: !!invalid, width: finalWidth });
|
||||
// Don't pass the width prop, as this causes an unnecessary amount of Emotion calls when auto sizing
|
||||
const styles = getInputStyles({ theme, invalid: !!invalid });
|
||||
|
||||
const suffix = suffixProp || (loading && <Spinner inline={true} />);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.wrapper, className)} data-testid="input-wrapper">
|
||||
<div
|
||||
className={cx(styles.wrapper, className)}
|
||||
style={{ width: finalWidth ? theme.spacing(finalWidth) : '100%' }} // Override emotion styles for the width prop
|
||||
data-testid="input-wrapper"
|
||||
>
|
||||
{!!addonBefore && <div className={styles.addon}>{addonBefore}</div>}
|
||||
<div className={styles.inputWrapper}>
|
||||
{prefix && (
|
||||
@@ -125,7 +130,7 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false, width }:
|
||||
css({
|
||||
label: 'input-wrapper',
|
||||
display: 'flex',
|
||||
width: width ? theme.spacing(width) : '100%',
|
||||
width: width ? theme.spacing(width) : '100%', // Not used in Input, as this causes performance issues with auto sizing
|
||||
height: theme.spacing(theme.components.height.md),
|
||||
borderRadius: theme.shape.radius.default,
|
||||
'&:hover': {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
|
||||
|
||||
import { ActionModel, Field, GrafanaTheme2, LinkModel } from '@grafana/data';
|
||||
|
||||
import { Button, DataLinkButton, Stack } from '..';
|
||||
import { Button, DataLinkButton, Icon, Stack } from '..';
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { Trans } from '../../utils/i18n';
|
||||
import { ActionButton } from '../Actions/ActionButton';
|
||||
@@ -16,6 +16,21 @@ interface VizTooltipFooterProps {
|
||||
export const ADD_ANNOTATION_ID = 'add-annotation-button';
|
||||
|
||||
const renderDataLinks = (dataLinks: LinkModel[], styles: ReturnType<typeof getStyles>) => {
|
||||
const oneClickLink = dataLinks.find((link) => link.oneClick === true);
|
||||
|
||||
if (oneClickLink != null) {
|
||||
return (
|
||||
<Stack direction="column" justifyContent="flex-start" gap={0.5}>
|
||||
<span className={styles.oneClickWrapper}>
|
||||
<Icon name="info-circle" size="lg" className={styles.infoIcon} />
|
||||
<Trans i18nKey="grafana-ui.viz-tooltip.footer-click-to-navigate">
|
||||
Click to open {{ linkTitle: oneClickLink.title }}
|
||||
</Trans>
|
||||
</span>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="column" justifyContent="flex-start" gap={0.5}>
|
||||
{dataLinks.map((link, i) => (
|
||||
@@ -35,14 +50,15 @@ const renderActions = (actions: ActionModel[]) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const VizTooltipFooter = ({ dataLinks, actions, annotate }: VizTooltipFooterProps) => {
|
||||
export const VizTooltipFooter = ({ dataLinks, actions = [], annotate }: VizTooltipFooterProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const hasOneClickLink = dataLinks.some((link) => link.oneClick === true);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{dataLinks?.length > 0 && <div className={styles.dataLinks}>{renderDataLinks(dataLinks, styles)}</div>}
|
||||
{actions && actions.length > 0 && <div className={styles.dataLinks}>{renderActions(actions)}</div>}
|
||||
{annotate != null && (
|
||||
{dataLinks.length > 0 && <div className={styles.dataLinks}>{renderDataLinks(dataLinks, styles)}</div>}
|
||||
{!hasOneClickLink && actions.length > 0 && <div className={styles.dataLinks}>{renderActions(actions)}</div>}
|
||||
{!hasOneClickLink && annotate != null && (
|
||||
<div className={styles.addAnnotations}>
|
||||
<Button icon="comment-alt" variant="secondary" size="sm" id={ADD_ANNOTATION_ID} onClick={annotate}>
|
||||
<Trans i18nKey="grafana-ui.viz-tooltip.footer-add-annotation">Add annotation</Trans>
|
||||
@@ -75,4 +91,12 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
background: 'none',
|
||||
},
|
||||
}),
|
||||
oneClickWrapper: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}),
|
||||
infoIcon: css({
|
||||
color: theme.colors.primary.main,
|
||||
paddingRight: theme.spacing(0.5),
|
||||
}),
|
||||
});
|
||||
@@ -27,7 +27,7 @@ export const enum TooltipHoverMode {
|
||||
xyOne,
|
||||
}
|
||||
|
||||
type GeDataLinksCallback = (seriesIdx: number, dataIdx: number) => LinkModel[];
|
||||
type GetDataLinksCallback = (seriesIdx: number, dataIdx: number) => LinkModel[];
|
||||
|
||||
interface TooltipPlugin2Props {
|
||||
config: UPlotConfigBuilder;
|
||||
@@ -42,7 +42,7 @@ interface TooltipPlugin2Props {
|
||||
clientZoom?: boolean;
|
||||
|
||||
onSelectRange?: OnSelectRangeCallback;
|
||||
getDataLinks?: GeDataLinksCallback;
|
||||
getDataLinks?: GetDataLinksCallback;
|
||||
|
||||
render: (
|
||||
u: uPlot,
|
||||
@@ -106,7 +106,7 @@ const MIN_ZOOM_DIST = 5;
|
||||
|
||||
const maybeZoomAction = (e?: MouseEvent | null) => e != null && !e.ctrlKey && !e.metaKey;
|
||||
|
||||
const getDataLinksFallback: GeDataLinksCallback = () => [];
|
||||
const getDataLinksFallback: GetDataLinksCallback = () => [];
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
@@ -194,11 +194,16 @@ export const TooltipPlugin2 = ({
|
||||
let offsetY = 0;
|
||||
|
||||
let selectedRange: TimeRange2 | null = null;
|
||||
let seriesIdxs: Array<number | null> = plot?.cursor.idxs!.slice()!;
|
||||
let seriesIdxs: Array<number | null> = [];
|
||||
let closestSeriesIdx: number | null = null;
|
||||
let viaSync = false;
|
||||
let dataLinks: LinkModel[] = [];
|
||||
|
||||
// for onceClick link rendering during mousemoves we use these pre-generated first links or actions
|
||||
// these will be wrong if the titles have interpolation using the hovered *value*
|
||||
// but this should be quite rare. we'll fix it if someone actually encounters this
|
||||
let persistentLinks: LinkModel[][] = [];
|
||||
|
||||
let pendingRender = false;
|
||||
let pendingPinned = false;
|
||||
|
||||
@@ -261,7 +266,7 @@ export const TooltipPlugin2 = ({
|
||||
dismiss,
|
||||
selectedRange,
|
||||
viaSync,
|
||||
dataLinks
|
||||
_isPinned ? dataLinks : closestSeriesIdx != null ? persistentLinks[closestSeriesIdx] : []
|
||||
)
|
||||
: null,
|
||||
dismiss,
|
||||
@@ -269,6 +274,9 @@ export const TooltipPlugin2 = ({
|
||||
|
||||
setState(state);
|
||||
|
||||
// TODO: set u.over.style.cursor = 'pointer' if we hovered a oneClick point
|
||||
// else revert to default...but only when the new pointer is different from prev
|
||||
|
||||
selectedRange = null;
|
||||
};
|
||||
|
||||
@@ -313,7 +321,7 @@ export const TooltipPlugin2 = ({
|
||||
);
|
||||
}
|
||||
|
||||
// this handles pinning
|
||||
// this handles pinning, 0-width range selection, and one-click
|
||||
u.over.addEventListener('click', (e) => {
|
||||
if (e.target === u.over) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
@@ -333,14 +341,19 @@ export const TooltipPlugin2 = ({
|
||||
|
||||
scheduleRender(false);
|
||||
}
|
||||
// only pinnable tooltip is visible *and* is within proximity to series/point
|
||||
else if (_isHovering && closestSeriesIdx != null && !_isPinned) {
|
||||
dataLinks = getLinksRef.current(closestSeriesIdx!, seriesIdxs[closestSeriesIdx!]!);
|
||||
// if tooltip visible, not pinned, and within proximity to a series/point
|
||||
else if (_isHovering && !_isPinned && closestSeriesIdx != null) {
|
||||
dataLinks = getLinksRef.current(closestSeriesIdx, seriesIdxs[closestSeriesIdx]!);
|
||||
const oneClickLink = dataLinks.find((dataLink) => dataLink.oneClick === true);
|
||||
|
||||
setTimeout(() => {
|
||||
_isPinned = true;
|
||||
scheduleRender(true);
|
||||
}, 0);
|
||||
if (oneClickLink != null) {
|
||||
window.open(oneClickLink.href, oneClickLink.target ?? '_self');
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
_isPinned = true;
|
||||
scheduleRender(true);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -511,6 +524,21 @@ export const TooltipPlugin2 = ({
|
||||
seriesIdxs = _plot?.cursor!.idxs!.slice()!;
|
||||
_someSeriesIdx = seriesIdxs.some((v, i) => i > 0 && v != null);
|
||||
|
||||
if (persistentLinks.length === 0) {
|
||||
persistentLinks = seriesIdxs.map((v, seriesIdx) => {
|
||||
if (seriesIdx > 0) {
|
||||
const links = getDataLinks(seriesIdx, seriesIdxs[seriesIdx]!);
|
||||
const oneClickLink = links.find((dataLink) => dataLink.oneClick === true);
|
||||
|
||||
if (oneClickLink) {
|
||||
return [oneClickLink];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
viaSync = u.cursor.event == null;
|
||||
let prevIsHovering = _isHovering;
|
||||
updateHovering();
|
||||
|
||||
@@ -14,49 +14,32 @@ import { Stack } from '../../components/Layout/Stack/Stack';
|
||||
import { Select } from '../../components/Select/Select';
|
||||
import { graphFieldOptions } from '../../components/uPlot/config';
|
||||
|
||||
const category = ['Axis'];
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export function addAxisConfig(
|
||||
builder: FieldConfigEditorBuilder<AxisConfig>,
|
||||
defaultConfig: AxisConfig,
|
||||
hideScale?: boolean
|
||||
) {
|
||||
const category = ['Axis'];
|
||||
|
||||
export function addAxisConfig(builder: FieldConfigEditorBuilder<AxisConfig>, defaultConfig: AxisConfig) {
|
||||
// options for axis appearance
|
||||
addAxisPlacement(builder);
|
||||
|
||||
builder.addTextInput({
|
||||
path: 'axisLabel',
|
||||
name: 'Label',
|
||||
category,
|
||||
defaultValue: '',
|
||||
settings: {
|
||||
placeholder: 'Optional text',
|
||||
expandTemplateVars: true,
|
||||
},
|
||||
showIf: (c) => c.axisPlacement !== AxisPlacement.Hidden,
|
||||
// Do not apply default settings to time and string fields which are used as x-axis fields in Time series and Bar chart panels
|
||||
shouldApply: (f) => f.type !== FieldType.time && f.type !== FieldType.string,
|
||||
});
|
||||
|
||||
addAxisWidth(builder);
|
||||
|
||||
builder
|
||||
.addRadio({
|
||||
path: 'axisPlacement',
|
||||
name: 'Placement',
|
||||
category,
|
||||
defaultValue: graphFieldOptions.axisPlacement[0].value,
|
||||
settings: {
|
||||
options: graphFieldOptions.axisPlacement,
|
||||
},
|
||||
})
|
||||
.addTextInput({
|
||||
path: 'axisLabel',
|
||||
name: 'Label',
|
||||
category,
|
||||
defaultValue: '',
|
||||
settings: {
|
||||
placeholder: 'Optional text',
|
||||
expandTemplateVars: true,
|
||||
},
|
||||
showIf: (c) => c.axisPlacement !== AxisPlacement.Hidden,
|
||||
// Do not apply default settings to time and string fields which are used as x-axis fields in Time series and Bar chart panels
|
||||
shouldApply: (f) => f.type !== FieldType.time && f.type !== FieldType.string,
|
||||
})
|
||||
.addNumberInput({
|
||||
path: 'axisWidth',
|
||||
name: 'Width',
|
||||
category,
|
||||
settings: {
|
||||
placeholder: 'Auto',
|
||||
},
|
||||
showIf: (c) => c.axisPlacement !== AxisPlacement.Hidden,
|
||||
})
|
||||
.addRadio({
|
||||
path: 'axisGridShow',
|
||||
name: 'Show grid lines',
|
||||
@@ -209,3 +192,32 @@ export const ScaleDistributionEditor = ({ value, onChange }: StandardEditorProps
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
export function addAxisWidth(builder: FieldConfigEditorBuilder<AxisConfig>) {
|
||||
builder.addNumberInput({
|
||||
path: 'axisWidth',
|
||||
name: 'Width',
|
||||
category,
|
||||
settings: {
|
||||
placeholder: 'Auto',
|
||||
},
|
||||
showIf: (c) => c.axisPlacement !== AxisPlacement.Hidden,
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function addAxisPlacement(
|
||||
builder: FieldConfigEditorBuilder<AxisConfig>,
|
||||
optionsFilter = (placement: AxisPlacement) => true
|
||||
) {
|
||||
builder.addRadio({
|
||||
path: 'axisPlacement',
|
||||
name: 'Placement',
|
||||
category,
|
||||
defaultValue: graphFieldOptions.axisPlacement[0].value,
|
||||
settings: {
|
||||
options: graphFieldOptions.axisPlacement.filter((placement) => optionsFilter(placement.value!)),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -500,12 +500,6 @@ func (hs *HTTPServer) deleteDashboard(c *contextmodel.ReqContext) response.Respo
|
||||
"error", err)
|
||||
}
|
||||
|
||||
// deletes all related public dashboard entities
|
||||
err = hs.PublicDashboardsApi.PublicDashboardService.DeleteByDashboard(c.Req.Context(), dash)
|
||||
if err != nil {
|
||||
hs.log.Error("Failed to delete public dashboard")
|
||||
}
|
||||
|
||||
err = hs.DashboardService.DeleteDashboard(c.Req.Context(), dash.ID, dash.UID, c.SignedInUser.GetOrgID())
|
||||
if err != nil {
|
||||
var dashboardErr dashboards.DashboardErr
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/db/dbtest"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
@@ -40,7 +41,7 @@ func TestHTTPServer_DeleteDashboardSnapshot(t *testing.T) {
|
||||
hs.DashboardService = svc
|
||||
|
||||
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
|
||||
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService)
|
||||
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService, hs.folderService, log.NewNopLogger())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ func TestHTTPServer_GetDashboard_AccessControl(t *testing.T) {
|
||||
hs.starService = startest.NewStarServiceFake()
|
||||
hs.dashboardProvisioningService = mockDashboardProvisioningService{}
|
||||
|
||||
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService)
|
||||
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService, hs.folderService, log.NewNopLogger())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -272,14 +272,12 @@ func TestHTTPServer_DeleteDashboardByUID_AccessControl(t *testing.T) {
|
||||
hs.LibraryPanelService = &mockLibraryPanelService{}
|
||||
hs.LibraryElementService = &libraryelementsfake.LibraryElementService{}
|
||||
|
||||
pubDashService := publicdashboards.NewFakePublicDashboardService(t)
|
||||
pubDashService.On("DeleteByDashboard", mock.Anything, mock.Anything).Return(nil).Maybe()
|
||||
middleware := publicdashboards.NewFakePublicDashboardMiddleware(t)
|
||||
license := licensingtest.NewFakeLicensing()
|
||||
license.On("FeatureEnabled", publicdashboardModels.FeaturePublicDashboardsEmailSharing).Return(false)
|
||||
hs.PublicDashboardsApi = api.ProvideApi(pubDashService, nil, hs.AccessControl, featuremgmt.WithFeatures(), middleware, hs.Cfg, license)
|
||||
hs.PublicDashboardsApi = api.ProvideApi(nil, nil, hs.AccessControl, featuremgmt.WithFeatures(), middleware, hs.Cfg, license)
|
||||
|
||||
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService)
|
||||
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService, hs.folderService, log.NewNopLogger())
|
||||
})
|
||||
}
|
||||
deleteDashboard := func(server *webtest.Server, permissions []accesscontrol.Permission) (*http.Response, error) {
|
||||
@@ -330,7 +328,7 @@ func TestHTTPServer_GetDashboardVersions_AccessControl(t *testing.T) {
|
||||
ExpectedDashboardVersion: &dashver.DashboardVersionDTO{},
|
||||
}
|
||||
|
||||
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService)
|
||||
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService, hs.folderService, log.NewNopLogger())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -833,11 +831,11 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr
|
||||
quotaService := quotatest.New(false, nil)
|
||||
folderSvc := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()),
|
||||
dashboardStore, folderStore, db, features,
|
||||
supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest())
|
||||
supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest())
|
||||
if dashboardService == nil {
|
||||
dashboardService, err = service.ProvideDashboardServiceImpl(
|
||||
cfg, dashboardStore, folderStore, features, folderPermissions,
|
||||
ac, folderSvc, fStore, nil, nil, nil, nil, quotaService, nil,
|
||||
ac, folderSvc, fStore, nil, nil, nil, nil, quotaService, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
dashboardService.(dashboards.PermissionsRegistrationService).RegisterDashboardPermissions(dashboardPermissions)
|
||||
@@ -845,7 +843,7 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr
|
||||
|
||||
dashboardProvisioningService, err := service.ProvideDashboardServiceImpl(
|
||||
cfg, dashboardStore, folderStore, features, folderPermissions,
|
||||
ac, folderSvc, fStore, nil, nil, nil, nil, quotaService, nil,
|
||||
ac, folderSvc, fStore, nil, nil, nil, nil, quotaService, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
+10
-560
@@ -3,16 +3,8 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
k8sErrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/client-go/dynamic"
|
||||
|
||||
claims "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/api/apierrors"
|
||||
@@ -20,13 +12,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
folderalpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||
"github.com/grafana/grafana/pkg/infra/slugify"
|
||||
internalfolders "github.com/grafana/grafana/pkg/registry/apis/folders"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
|
||||
@@ -36,9 +23,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/libraryelements/model"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/search"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/grafana/grafana/pkg/util/errhttp"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
@@ -57,38 +42,16 @@ func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authoriz
|
||||
folderPermissionRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsWrite, uidScope)), routing.Wrap(hs.UpdateFolderPermissions))
|
||||
})
|
||||
})
|
||||
if hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesFolders) && !hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesFoldersServiceV2) {
|
||||
// Use k8s client to implement legacy API
|
||||
handler := newFolderK8sHandler(hs)
|
||||
folderRoute.Post("/", handler.createFolder)
|
||||
folderRoute.Get("/", handler.getFolders)
|
||||
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
|
||||
folderUidRoute.Put("/", handler.updateFolder)
|
||||
folderUidRoute.Delete("/", handler.deleteFolder)
|
||||
folderUidRoute.Get("/", handler.getFolder)
|
||||
if hs.Features.IsEnabledGlobally(featuremgmt.FlagK8SFolderCounts) {
|
||||
folderUidRoute.Get("/counts", handler.countFolderContent)
|
||||
} else {
|
||||
folderUidRoute.Get("/counts", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts))
|
||||
}
|
||||
if hs.Features.IsEnabledGlobally(featuremgmt.FlagK8SFolderMove) {
|
||||
folderUidRoute.Post("/move", handler.moveFolder)
|
||||
} else {
|
||||
folderUidRoute.Post("/move", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder))
|
||||
}
|
||||
folderUidRoute.Get("parents", handler.getFolderParents)
|
||||
})
|
||||
} else {
|
||||
folderRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersCreate)), routing.Wrap(hs.CreateFolder))
|
||||
folderRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders))
|
||||
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
|
||||
folderUidRoute.Put("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder))
|
||||
folderUidRoute.Delete("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder))
|
||||
folderUidRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID))
|
||||
folderUidRoute.Get("/counts", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts))
|
||||
folderUidRoute.Post("/move", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder))
|
||||
})
|
||||
}
|
||||
|
||||
folderRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersCreate)), routing.Wrap(hs.CreateFolder))
|
||||
folderRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders))
|
||||
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
|
||||
folderUidRoute.Put("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder))
|
||||
folderUidRoute.Delete("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder))
|
||||
folderUidRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID))
|
||||
folderUidRoute.Get("/counts", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts))
|
||||
folderUidRoute.Post("/move", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -683,516 +646,3 @@ type GetFolderDescendantCountsResponse struct {
|
||||
// in: body
|
||||
Body folder.DescendantCounts `json:"body"`
|
||||
}
|
||||
|
||||
type folderK8sHandler struct {
|
||||
namespacer request.NamespaceMapper
|
||||
gvr schema.GroupVersionResource
|
||||
clientConfigProvider grafanaapiserver.DirectRestConfigProvider
|
||||
// #TODO check if it makes more sense to move this to FolderAPIBuilder
|
||||
accesscontrolService accesscontrol.Service
|
||||
userService user.Service
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------------------
|
||||
// Folder k8s wrapper functions
|
||||
//-----------------------------------------------------------------------------------------
|
||||
|
||||
func newFolderK8sHandler(hs *HTTPServer) *folderK8sHandler {
|
||||
return &folderK8sHandler{
|
||||
gvr: folderalpha1.FolderResourceInfo.GroupVersionResource(),
|
||||
namespacer: request.GetNamespaceMapper(hs.Cfg),
|
||||
clientConfigProvider: hs.clientConfigProvider,
|
||||
accesscontrolService: hs.accesscontrolService,
|
||||
userService: hs.userService,
|
||||
}
|
||||
}
|
||||
|
||||
func (fk8s *folderK8sHandler) createFolder(c *contextmodel.ReqContext) {
|
||||
client, ok := fk8s.getClient(c)
|
||||
if !ok {
|
||||
return // error is already sent
|
||||
}
|
||||
cmd := &folder.CreateFolderCommand{}
|
||||
if err := web.Bind(c.Req, cmd); err != nil {
|
||||
c.JsonApiErr(http.StatusBadRequest, "bad request data", err)
|
||||
return
|
||||
}
|
||||
obj, err := internalfolders.LegacyCreateCommandToUnstructured(cmd)
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
out, err := client.Create(c.Req.Context(), obj, v1.CreateOptions{})
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
fk8s.accesscontrolService.ClearUserPermissionCache(c.SignedInUser)
|
||||
folderDTO, err := fk8s.newToFolderDto(c, *out, c.SignedInUser.GetOrgID())
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, folderDTO)
|
||||
}
|
||||
|
||||
func (fk8s *folderK8sHandler) getFolders(c *contextmodel.ReqContext) {
|
||||
// NOTE: the current implementation is temporary and it will be
|
||||
// replaced by a proper indexing service/search API
|
||||
// Also, the current implementation does not support pagination
|
||||
|
||||
parentUid := strings.ToUpper(c.Query("parentUid"))
|
||||
|
||||
client, ok := fk8s.getClient(c)
|
||||
if !ok {
|
||||
return // error is already sent
|
||||
}
|
||||
|
||||
// check that parent exists
|
||||
if parentUid != "" {
|
||||
_, err := client.Get(c.Req.Context(), c.Query("parentUid"), v1.GetOptions{})
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
out, err := client.List(c.Req.Context(), v1.ListOptions{})
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
hits := make([]dtos.FolderSearchHit, 0)
|
||||
for _, item := range out.Items {
|
||||
// convert item to legacy folder format
|
||||
f, _ := internalfolders.UnstructuredToLegacyFolder(item, c.SignedInUser.GetOrgID())
|
||||
if f == nil {
|
||||
fk8s.writeError(c, fmt.Errorf("unable covert unstructured item to legacy folder"))
|
||||
return
|
||||
}
|
||||
|
||||
// it we are at root level, skip subfolder
|
||||
if parentUid == "" && f.ParentUID != "" {
|
||||
continue // query filter
|
||||
}
|
||||
// if we are at a nested folder, then skip folders that don't belong to parentUid
|
||||
if parentUid != "" && strings.ToUpper(f.ParentUID) != parentUid {
|
||||
continue
|
||||
}
|
||||
|
||||
hits = append(hits, dtos.FolderSearchHit{
|
||||
ID: f.ID, // nolint:staticcheck
|
||||
UID: f.UID,
|
||||
Title: f.Title,
|
||||
ParentUID: f.ParentUID,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, hits)
|
||||
}
|
||||
|
||||
func (fk8s *folderK8sHandler) countFolderContent(c *contextmodel.ReqContext) {
|
||||
client, ok := fk8s.getClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
uid := web.Params(c.Req)[":uid"]
|
||||
|
||||
counts, err := client.Get(c.Req.Context(), uid, v1.GetOptions{}, "counts")
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := toFolderLegacyCounts(counts)
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (fk8s *folderK8sHandler) getFolderParents(c *contextmodel.ReqContext) {
|
||||
client, ok := fk8s.getClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
uid := web.Params(c.Req)[":uid"]
|
||||
|
||||
out, err := client.Get(c.Req.Context(), uid, v1.GetOptions{}, "parents")
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (fk8s *folderK8sHandler) getFolder(c *contextmodel.ReqContext) {
|
||||
client, ok := fk8s.getClient(c)
|
||||
if !ok {
|
||||
return // error is already sent
|
||||
}
|
||||
uid := web.Params(c.Req)[":uid"]
|
||||
|
||||
var out *unstructured.Unstructured
|
||||
var err error
|
||||
|
||||
if uid == accesscontrol.GeneralFolderUID {
|
||||
out = &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"spec": map[string]interface{}{
|
||||
"title": folder.RootFolder.Title,
|
||||
"description": folder.RootFolder.Description,
|
||||
},
|
||||
},
|
||||
}
|
||||
out.SetName(folder.RootFolder.UID)
|
||||
} else {
|
||||
out, err = client.Get(c.Req.Context(), uid, v1.GetOptions{})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
folderDTO, err := fk8s.newToFolderDto(c, *out, c.SignedInUser.GetOrgID())
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, folderDTO)
|
||||
}
|
||||
|
||||
func (fk8s *folderK8sHandler) deleteFolder(c *contextmodel.ReqContext) {
|
||||
client, ok := fk8s.getClient(c)
|
||||
if !ok {
|
||||
return // error is already sent
|
||||
}
|
||||
uid := web.Params(c.Req)[":uid"]
|
||||
err := client.Delete(c.Req.Context(), uid, v1.DeleteOptions{})
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, "")
|
||||
}
|
||||
|
||||
func (fk8s *folderK8sHandler) updateFolder(c *contextmodel.ReqContext) {
|
||||
client, ok := fk8s.getClient(c)
|
||||
if !ok {
|
||||
return // error is already sent
|
||||
}
|
||||
|
||||
var ctx = c.Req.Context()
|
||||
|
||||
cmd := &folder.UpdateFolderCommand{}
|
||||
if err := web.Bind(c.Req, cmd); err != nil {
|
||||
c.JsonApiErr(http.StatusBadRequest, "bad request data", err)
|
||||
return
|
||||
}
|
||||
cmd.UID = web.Params(c.Req)[":uid"]
|
||||
|
||||
obj, err := client.Get(ctx, cmd.UID, v1.GetOptions{})
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := internalfolders.LegacyUpdateCommandToUnstructured(obj, cmd)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := client.Update(ctx, updated, v1.UpdateOptions{})
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
folderDTO, err := fk8s.newToFolderDto(c, *out, c.SignedInUser.GetOrgID())
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, folderDTO)
|
||||
}
|
||||
|
||||
func (fk8s *folderK8sHandler) moveFolder(c *contextmodel.ReqContext) {
|
||||
client, ok := fk8s.getClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Req.Context()
|
||||
|
||||
cmd := folder.MoveFolderCommand{}
|
||||
if err := web.Bind(c.Req, &cmd); err != nil {
|
||||
c.JsonApiErr(http.StatusBadRequest, "bad request data", err)
|
||||
return
|
||||
}
|
||||
cmd.UID = web.Params(c.Req)[":uid"]
|
||||
|
||||
obj, err := client.Get(ctx, cmd.UID, v1.GetOptions{})
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
obj, err = internalfolders.LegacyMoveCommandToUnstructured(obj, cmd)
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := client.Update(c.Req.Context(), obj, v1.UpdateOptions{})
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
folderDTO, err := fk8s.newToFolderDto(c, *out, c.SignedInUser.GetOrgID())
|
||||
if err != nil {
|
||||
fk8s.writeError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, folderDTO)
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------------------
|
||||
// Utility functions
|
||||
//-----------------------------------------------------------------------------------------
|
||||
|
||||
func (fk8s *folderK8sHandler) getClient(c *contextmodel.ReqContext) (dynamic.ResourceInterface, bool) {
|
||||
dyn, err := dynamic.NewForConfig(fk8s.clientConfigProvider.GetDirectRestConfig(c))
|
||||
if err != nil {
|
||||
c.JsonApiErr(500, "client", err)
|
||||
return nil, false
|
||||
}
|
||||
return dyn.Resource(fk8s.gvr).Namespace(fk8s.namespacer(c.OrgID)), true
|
||||
}
|
||||
|
||||
func (fk8s *folderK8sHandler) writeError(c *contextmodel.ReqContext, err error) {
|
||||
//nolint:errorlint
|
||||
statusError, ok := err.(*k8sErrors.StatusError)
|
||||
if ok {
|
||||
message := statusError.Status().Message
|
||||
// #TODO: Is there a better way to set the correct meesage? Instead of "access denied to folder", currently we are
|
||||
// returning something like `folders.folder.grafana.app is forbidden: User "" cannot create resource "folders" in
|
||||
// API group "folder.grafana.app" in the namespace "default": folder``
|
||||
if statusError.Status().Code == http.StatusForbidden {
|
||||
message = dashboards.ErrFolderAccessDenied.Error()
|
||||
}
|
||||
c.JsonApiErr(int(statusError.Status().Code), message, err)
|
||||
return
|
||||
}
|
||||
errhttp.Write(c.Req.Context(), err, c.Resp)
|
||||
}
|
||||
|
||||
func (fk8s *folderK8sHandler) newToFolderDto(c *contextmodel.ReqContext, item unstructured.Unstructured, orgID int64) (dtos.Folder, error) {
|
||||
f, createdBy := internalfolders.UnstructuredToLegacyFolder(item, orgID)
|
||||
|
||||
dontCheckCanView := false
|
||||
checkCanView := true
|
||||
// no need to check view permission for the starting folder since it's already checked by the callers
|
||||
folderDTO, err := fk8s.toDTO(c, f, createdBy, dontCheckCanView)
|
||||
if err != nil {
|
||||
return dtos.Folder{}, err
|
||||
}
|
||||
|
||||
if len(f.Fullpath) == 0 || len(f.FullpathUIDs) == 0 {
|
||||
return folderDTO, nil
|
||||
}
|
||||
|
||||
parentsFullPath, err := internalfolders.GetParentTitles(f.Fullpath)
|
||||
if err != nil {
|
||||
return dtos.Folder{}, err
|
||||
}
|
||||
parentsFullPathUIDs := strings.Split(f.FullpathUIDs, "/")
|
||||
|
||||
// The first part of the path is the newly created folder which we don't need to include
|
||||
// in the parents field
|
||||
if len(parentsFullPath) < 2 || len(parentsFullPathUIDs) < 2 {
|
||||
return folderDTO, nil
|
||||
}
|
||||
|
||||
parents := []dtos.Folder{}
|
||||
for i, v := range parentsFullPath[1:] {
|
||||
slug := slugify.Slugify(v)
|
||||
uid := parentsFullPathUIDs[1:][i]
|
||||
url := dashboards.GetFolderURL(uid, slug)
|
||||
|
||||
ff := folder.Folder{
|
||||
UID: uid,
|
||||
Title: v,
|
||||
URL: url,
|
||||
}
|
||||
parentDTO, err := fk8s.toDTO(c, &ff, "", checkCanView)
|
||||
if err != nil {
|
||||
// #TODO should we log this error?
|
||||
return dtos.Folder{}, err
|
||||
}
|
||||
|
||||
parents = append(parents, parentDTO)
|
||||
}
|
||||
|
||||
folderDTO.Parents = parents
|
||||
|
||||
return folderDTO, nil
|
||||
}
|
||||
|
||||
func toUID(rawIdentifier string) string {
|
||||
// #TODO Is there a preexisting function we can use instead, something along the lines of UserIdentifier?
|
||||
parts := strings.Split(rawIdentifier, ":")
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
func (fk8s *folderK8sHandler) toDTO(c *contextmodel.ReqContext, fold *folder.Folder, createdBy string, checkCanView bool) (dtos.Folder, error) {
|
||||
// #TODO revisit how/where we get orgID
|
||||
ctx := c.Req.Context()
|
||||
|
||||
g, err := guardian.NewByFolder(c.Req.Context(), fold, c.SignedInUser.GetOrgID(), c.SignedInUser)
|
||||
if err != nil {
|
||||
return dtos.Folder{}, err
|
||||
}
|
||||
|
||||
canEdit, _ := g.CanEdit()
|
||||
canSave, _ := g.CanSave()
|
||||
canAdmin, _ := g.CanAdmin()
|
||||
canDelete, _ := g.CanDelete()
|
||||
|
||||
// Finding creator and last updater of the folder
|
||||
updater, creator := anonString, anonString
|
||||
// #TODO refactor the various conversions of the folder so that we either set created by in folder.Folder or
|
||||
// we convert from unstructured to folder DTO without an intermediate conversion to folder.Folder
|
||||
if len(createdBy) > 0 {
|
||||
creator = fk8s.getIdentityName(ctx, toUID(createdBy))
|
||||
}
|
||||
if len(createdBy) > 0 {
|
||||
updater = fk8s.getIdentityName(ctx, toUID(createdBy))
|
||||
}
|
||||
|
||||
acMetadata, _ := fk8s.getFolderACMetadata(c, fold)
|
||||
|
||||
if checkCanView {
|
||||
canView, _ := g.CanView()
|
||||
if !canView {
|
||||
return dtos.Folder{
|
||||
UID: REDACTED,
|
||||
Title: REDACTED,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
metrics.MFolderIDsAPICount.WithLabelValues(metrics.NewToFolderDTO).Inc()
|
||||
|
||||
return dtos.Folder{
|
||||
ID: fold.ID, // nolint:staticcheck
|
||||
UID: fold.UID,
|
||||
Title: fold.Title,
|
||||
URL: fold.URL,
|
||||
HasACL: fold.HasACL,
|
||||
CanSave: canSave,
|
||||
CanEdit: canEdit,
|
||||
CanAdmin: canAdmin,
|
||||
CanDelete: canDelete,
|
||||
CreatedBy: creator,
|
||||
Created: fold.Created,
|
||||
UpdatedBy: updater,
|
||||
Updated: fold.Updated,
|
||||
// #TODO version doesn't seem to be used--confirm or set it properly
|
||||
Version: fold.Version,
|
||||
AccessControl: acMetadata,
|
||||
ParentUID: fold.ParentUID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (fk8s *folderK8sHandler) getIdentityName(ctx context.Context, uid string) string {
|
||||
ctx, span := tracer.Start(ctx, "api.getUserLogin")
|
||||
defer span.End()
|
||||
|
||||
ident, err := fk8s.userService.GetByUID(ctx, &user.GetUserByUIDQuery{
|
||||
UID: uid,
|
||||
})
|
||||
if err != nil {
|
||||
return anonString
|
||||
}
|
||||
|
||||
if ident.IsServiceAccount {
|
||||
return ident.Name
|
||||
}
|
||||
return ident.Login
|
||||
}
|
||||
|
||||
func (fk8s *folderK8sHandler) getFolderACMetadata(c *contextmodel.ReqContext, f *folder.Folder) (accesscontrol.Metadata, error) {
|
||||
if !c.QueryBool("accesscontrol") {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
folderIDs, err := getParents(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allMetadata := getMultiAccessControlMetadata(c, dashboards.ScopeFoldersPrefix, folderIDs)
|
||||
metadata := map[string]bool{}
|
||||
// Flatten metadata - if any parent has a permission, the child folder inherits it
|
||||
for _, md := range allMetadata {
|
||||
for action := range md {
|
||||
metadata[action] = true
|
||||
}
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func getParents(f *folder.Folder) (map[string]bool, error) {
|
||||
folderIDs := map[string]bool{f.UID: true}
|
||||
if (f.UID == accesscontrol.GeneralFolderUID) || (f.UID == folder.SharedWithMeFolderUID) {
|
||||
return folderIDs, nil
|
||||
}
|
||||
|
||||
parentsFullPathUIDs := strings.Split(f.FullpathUIDs, "/")
|
||||
// The first part of the path is the newly created folder which we don't need to check here
|
||||
if len(parentsFullPathUIDs) < 2 {
|
||||
return folderIDs, nil
|
||||
}
|
||||
|
||||
for _, uid := range parentsFullPathUIDs[1:] {
|
||||
folderIDs[uid] = true
|
||||
}
|
||||
|
||||
return folderIDs, nil
|
||||
}
|
||||
|
||||
func toFolderLegacyCounts(u *unstructured.Unstructured) (*folder.DescendantCounts, error) {
|
||||
ds, err := folderalpha1.UnstructuredToDescendantCounts(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out = make(folder.DescendantCounts)
|
||||
for _, v := range ds.Counts {
|
||||
// if stats come from unified storage, we will use them
|
||||
if v.Group != "sql-fallback" {
|
||||
out[v.Resource] = v.Count
|
||||
continue
|
||||
}
|
||||
// if stats are from single tenant DB and they are not in unified storage, we will use them
|
||||
if _, ok := out[v.Resource]; !ok {
|
||||
out[v.Resource] = v.Count
|
||||
}
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -461,7 +461,7 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog
|
||||
actionSets := resourcepermissions.NewActionSetService(features)
|
||||
fStore := folderimpl.ProvideStore(sc.db)
|
||||
folderServiceWithFlagOn := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore,
|
||||
folderStore, sc.db, features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest())
|
||||
folderStore, sc.db, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest())
|
||||
acSvc := acimpl.ProvideOSSService(
|
||||
sc.cfg, acdb.ProvideService(sc.db), actionSets, localcache.ProvideService(),
|
||||
features, tracing.InitializeTracerForTest(), zanzana.NewNoopClient(), sc.db, permreg.ProvidePermissionRegistry(), nil, folderServiceWithFlagOn,
|
||||
@@ -472,7 +472,7 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog
|
||||
dashboardSvc, err := dashboardservice.ProvideDashboardServiceImpl(
|
||||
sc.cfg, dashStore, folderStore,
|
||||
features, folderPermissions, ac,
|
||||
folderServiceWithFlagOn, fStore, nil, nil, nil, nil, quotaSrv, nil,
|
||||
folderServiceWithFlagOn, fStore, nil, nil, nil, nil, quotaSrv, nil, nil,
|
||||
)
|
||||
require.NoError(b, err)
|
||||
|
||||
@@ -495,7 +495,7 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog
|
||||
}
|
||||
|
||||
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
|
||||
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService)
|
||||
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService, hs.folderService, log.NewNopLogger())
|
||||
|
||||
m.Get("/api/folders", hs.GetFolders)
|
||||
m.Get("/api/search", hs.Search)
|
||||
|
||||
+1
-347
@@ -8,19 +8,16 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
clientrest "k8s.io/client-go/rest"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
conversions "github.com/grafana/grafana/pkg/registry/apis/folders"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
||||
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||
@@ -537,349 +534,6 @@ func (m mockClientConfigProvider) GetRestConfigForBackgroundWorker(requester fun
|
||||
|
||||
func (m mockClientConfigProvider) DirectlyServeHTTP(w http.ResponseWriter, r *http.Request) {}
|
||||
|
||||
func TestUpdateFolderLegacyAndUnifiedStorage(t *testing.T) {
|
||||
testuser := &user.User{ID: 99, UID: "fdxsqt7t5ryf4a", Login: "testuser"}
|
||||
testSignedInUser := &user.SignedInUser{UserID: 99, UserUID: "fdxsqt7t5ryf4a", Login: "testuser"}
|
||||
|
||||
legacyFolder := folder.Folder{
|
||||
UID: "ady4yobv315a8e",
|
||||
Title: "Example folder 226",
|
||||
URL: "/dashboards/f/ady4yobv315a8e/example-folder-226",
|
||||
CreatedBy: 99,
|
||||
CreatedByUID: "fdxsqt7t5ryf4a",
|
||||
Created: time.Date(2024, time.November, 29, 0, 42, 34, 0, time.UTC),
|
||||
UpdatedBy: 99,
|
||||
UpdatedByUID: "fdxsqt7t5ryf4a",
|
||||
Updated: time.Date(2024, time.November, 29, 0, 42, 34, 0, time.UTC),
|
||||
Version: 3,
|
||||
}
|
||||
|
||||
namespacer := func(_ int64) string { return "1" }
|
||||
unifiedStorageFolder, err := conversions.LegacyFolderToUnstructured(&legacyFolder, namespacer)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedFolder := dtos.Folder{
|
||||
UID: legacyFolder.UID,
|
||||
OrgID: 0,
|
||||
Title: legacyFolder.Title,
|
||||
URL: legacyFolder.URL,
|
||||
HasACL: false,
|
||||
CanSave: false,
|
||||
CanEdit: true,
|
||||
CanAdmin: false,
|
||||
CanDelete: false,
|
||||
CreatedBy: "testuser",
|
||||
Created: legacyFolder.Created,
|
||||
UpdatedBy: "testuser",
|
||||
Updated: legacyFolder.Updated,
|
||||
Version: legacyFolder.Version,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /apis/folder.grafana.app/v0alpha1/namespaces/default/folders/ady4yobv315a8e", func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
err := json.NewEncoder(w).Encode(unifiedStorageFolder)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
mux.HandleFunc("PUT /apis/folder.grafana.app/v0alpha1/namespaces/default/folders/ady4yobv315a8e", func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
err := json.NewEncoder(w).Encode(unifiedStorageFolder)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
folderApiServerMock := httptest.NewServer(mux)
|
||||
defer folderApiServerMock.Close()
|
||||
|
||||
t.Run("happy path", func(t *testing.T) {
|
||||
type testCase struct {
|
||||
description string
|
||||
folderUID string
|
||||
legacyFolder folder.Folder
|
||||
expectedFolder dtos.Folder
|
||||
expectedFolderServiceError error
|
||||
unifiedStorageEnabled bool
|
||||
unifiedStorageMode grafanarest.DualWriterMode
|
||||
expectedCode int
|
||||
}
|
||||
|
||||
tcs := []testCase{
|
||||
{
|
||||
description: "Happy Path - Legacy",
|
||||
expectedCode: http.StatusOK,
|
||||
legacyFolder: legacyFolder,
|
||||
folderUID: legacyFolder.UID,
|
||||
expectedFolder: expectedFolder,
|
||||
unifiedStorageEnabled: false,
|
||||
},
|
||||
{
|
||||
description: "Happy Path - Unified storage, mode 1",
|
||||
expectedCode: http.StatusOK,
|
||||
legacyFolder: legacyFolder,
|
||||
folderUID: legacyFolder.UID,
|
||||
expectedFolder: expectedFolder,
|
||||
unifiedStorageEnabled: true,
|
||||
unifiedStorageMode: grafanarest.Mode1,
|
||||
},
|
||||
{
|
||||
description: "Happy Path - Unified storage, mode 2",
|
||||
expectedCode: http.StatusOK,
|
||||
legacyFolder: legacyFolder,
|
||||
folderUID: legacyFolder.UID,
|
||||
expectedFolder: expectedFolder,
|
||||
unifiedStorageEnabled: true,
|
||||
unifiedStorageMode: grafanarest.Mode2,
|
||||
},
|
||||
{
|
||||
description: "Happy Path - Unified storage, mode 3",
|
||||
expectedCode: http.StatusOK,
|
||||
legacyFolder: legacyFolder,
|
||||
folderUID: legacyFolder.UID,
|
||||
expectedFolder: expectedFolder,
|
||||
unifiedStorageEnabled: true,
|
||||
unifiedStorageMode: grafanarest.Mode3,
|
||||
},
|
||||
{
|
||||
description: "Happy Path - Unified storage, mode 4",
|
||||
expectedCode: http.StatusOK,
|
||||
legacyFolder: legacyFolder,
|
||||
folderUID: legacyFolder.UID,
|
||||
expectedFolder: expectedFolder,
|
||||
unifiedStorageEnabled: true,
|
||||
unifiedStorageMode: grafanarest.Mode4,
|
||||
},
|
||||
{
|
||||
description: "Folder Not Found - Legacy",
|
||||
expectedCode: http.StatusNotFound,
|
||||
legacyFolder: legacyFolder,
|
||||
folderUID: "notfound",
|
||||
expectedFolder: expectedFolder,
|
||||
unifiedStorageEnabled: false,
|
||||
expectedFolderServiceError: dashboards.ErrFolderNotFound,
|
||||
},
|
||||
{
|
||||
description: "Folder Not Found - Unified storage, mode 1",
|
||||
expectedCode: http.StatusNotFound,
|
||||
legacyFolder: legacyFolder,
|
||||
folderUID: "notfound",
|
||||
expectedFolder: expectedFolder,
|
||||
unifiedStorageEnabled: true,
|
||||
unifiedStorageMode: grafanarest.Mode1,
|
||||
},
|
||||
{
|
||||
description: "Folder Not Found - Unified storage, mode 2",
|
||||
expectedCode: http.StatusNotFound,
|
||||
legacyFolder: legacyFolder,
|
||||
folderUID: "notfound",
|
||||
expectedFolder: expectedFolder,
|
||||
unifiedStorageEnabled: true,
|
||||
unifiedStorageMode: grafanarest.Mode2,
|
||||
},
|
||||
{
|
||||
description: "Folder Not Found - Unified storage, mode 3",
|
||||
expectedCode: http.StatusNotFound,
|
||||
legacyFolder: legacyFolder,
|
||||
folderUID: "notfound",
|
||||
expectedFolder: expectedFolder,
|
||||
unifiedStorageEnabled: true,
|
||||
unifiedStorageMode: grafanarest.Mode3,
|
||||
},
|
||||
{
|
||||
description: "Folder Not Found - Unified storage, mode 4",
|
||||
expectedCode: http.StatusNotFound,
|
||||
legacyFolder: legacyFolder,
|
||||
folderUID: "notfound",
|
||||
expectedFolder: expectedFolder,
|
||||
unifiedStorageEnabled: true,
|
||||
unifiedStorageMode: grafanarest.Mode4,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
setUpRBACGuardian(t)
|
||||
|
||||
cfg := setting.NewCfg()
|
||||
cfg.UnifiedStorage = map[string]setting.UnifiedStorageConfig{
|
||||
folderv0alpha1.RESOURCEGROUP: {
|
||||
DualWriterMode: tc.unifiedStorageMode,
|
||||
},
|
||||
}
|
||||
|
||||
featuresArr := []any{featuremgmt.FlagNestedFolders}
|
||||
if tc.unifiedStorageEnabled {
|
||||
featuresArr = append(featuresArr, featuremgmt.FlagKubernetesFolders)
|
||||
}
|
||||
|
||||
server := SetupAPITestServer(t, func(hs *HTTPServer) {
|
||||
hs.Cfg = cfg
|
||||
hs.folderService = &foldertest.FakeService{
|
||||
ExpectedFolder: &tc.legacyFolder,
|
||||
ExpectedError: tc.expectedFolderServiceError,
|
||||
}
|
||||
hs.QuotaService = quotatest.New(false, nil)
|
||||
hs.SearchService = &mockSearchService{
|
||||
ExpectedResult: model.HitList{},
|
||||
}
|
||||
hs.userService = &usertest.FakeUserService{
|
||||
ExpectedUser: testuser,
|
||||
ExpectedSignedInUser: testSignedInUser,
|
||||
}
|
||||
hs.Features = featuremgmt.WithFeatures(
|
||||
featuresArr...,
|
||||
)
|
||||
hs.clientConfigProvider = mockClientConfigProvider{
|
||||
host: folderApiServerMock.URL,
|
||||
}
|
||||
})
|
||||
|
||||
req := server.NewRequest(http.MethodPut, fmt.Sprintf("/api/folders/%s", tc.folderUID), strings.NewReader(`{"title":"new title"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{
|
||||
1: accesscontrol.GroupScopesByActionContext(context.Background(), []accesscontrol.Permission{
|
||||
{Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersAll},
|
||||
{Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID("ady4yobv315a8e")},
|
||||
}),
|
||||
}})
|
||||
|
||||
res, err := server.Send(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, tc.expectedCode, res.StatusCode)
|
||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||
|
||||
if tc.expectedCode == http.StatusOK {
|
||||
body := dtos.Folder{}
|
||||
require.NoError(t, json.NewDecoder(res.Body).Decode(&body))
|
||||
|
||||
//nolint:staticcheck
|
||||
body.ID = 0
|
||||
body.Version = 0
|
||||
tc.expectedFolder.Version = 0
|
||||
require.Equal(t, tc.expectedFolder, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestToFolderCounts(t *testing.T) {
|
||||
var tests = []struct {
|
||||
name string
|
||||
input *unstructured.Unstructured
|
||||
expected *folder.DescendantCounts
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "with only counts from unified storage",
|
||||
input: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "folder.grafana.app/v0alpha1",
|
||||
"counts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"group": "alpha",
|
||||
"resource": "folders",
|
||||
"count": int64(1),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"group": "alpha",
|
||||
"resource": "dashboards",
|
||||
"count": int64(3),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"group": "alpha",
|
||||
"resource": "alertRules",
|
||||
"count": int64(0),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: &folder.DescendantCounts{
|
||||
"folders": 1,
|
||||
"dashboards": 3,
|
||||
"alertRules": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with counts from both storages",
|
||||
input: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "folder.grafana.app/v0alpha1",
|
||||
"counts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"group": "alpha",
|
||||
"resource": "folders",
|
||||
"count": int64(1),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"group": "alpha",
|
||||
"resource": "dashboards",
|
||||
"count": int64(3),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"group": "sql-fallback",
|
||||
"resource": "folders",
|
||||
"count": int64(0),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: &folder.DescendantCounts{
|
||||
"folders": 1,
|
||||
"dashboards": 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "it uses the values from sql-fallaback if not found in unified storage",
|
||||
input: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "folder.grafana.app/v0alpha1",
|
||||
"counts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"group": "alpha",
|
||||
"resource": "dashboards",
|
||||
"count": int64(3),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"group": "sql-fallback",
|
||||
"resource": "folders",
|
||||
"count": int64(2),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: &folder.DescendantCounts{
|
||||
"folders": 2,
|
||||
"dashboards": 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "malformed input",
|
||||
input: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "folder.grafana.app/v0alpha1",
|
||||
"counts": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actual, err := toFolderLegacyCounts(tc.input)
|
||||
if tc.expectError {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// for now, test only the general folder
|
||||
func TestGetFolderLegacyAndUnifiedStorage(t *testing.T) {
|
||||
testuser := &user.User{ID: 99, UID: "fdxsqt7t5ryf4a", Login: "testuser"}
|
||||
@@ -976,7 +630,7 @@ func TestGetFolderLegacyAndUnifiedStorage(t *testing.T) {
|
||||
|
||||
featuresArr := []any{featuremgmt.FlagNestedFolders}
|
||||
if tc.unifiedStorageEnabled {
|
||||
featuresArr = append(featuresArr, featuremgmt.FlagKubernetesFolders)
|
||||
featuresArr = append(featuresArr, featuremgmt.FlagKubernetesFoldersServiceV2)
|
||||
}
|
||||
|
||||
server := SetupAPITestServer(t, func(hs *HTTPServer) {
|
||||
|
||||
@@ -3,7 +3,7 @@ module github.com/grafana/grafana/pkg/apimachinery
|
||||
go 1.23.1
|
||||
|
||||
require (
|
||||
github.com/grafana/authlib v0.0.0-20250120145936-5f0e28e7a87c // @grafana/identity-access-team
|
||||
github.com/grafana/authlib v0.0.0-20250123104008-e99947858901 // @grafana/identity-access-team
|
||||
github.com/grafana/authlib/types v0.0.0-20250120145936-5f0e28e7a87c // @grafana/identity-access-team
|
||||
github.com/stretchr/testify v1.10.0
|
||||
k8s.io/apimachinery v0.32.0
|
||||
|
||||
@@ -32,8 +32,8 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grafana/authlib v0.0.0-20250120145936-5f0e28e7a87c h1:duHQ8Bih3nt9p68aQdp+22a6mFBLpK4IOURhEq+Cvk8=
|
||||
github.com/grafana/authlib v0.0.0-20250120145936-5f0e28e7a87c/go.mod h1:/gYfphsNu9v1qYWXxpv1NSvMEMSwvdf8qb8YlgwIRl8=
|
||||
github.com/grafana/authlib v0.0.0-20250123104008-e99947858901 h1:nqV1YrtX+ZG+EYB5dcmFMWhg2Y038OMaAHAADbOC9RA=
|
||||
github.com/grafana/authlib v0.0.0-20250123104008-e99947858901/go.mod h1:/gYfphsNu9v1qYWXxpv1NSvMEMSwvdf8qb8YlgwIRl8=
|
||||
github.com/grafana/authlib/types v0.0.0-20250120145936-5f0e28e7a87c h1:b0sPDtt33uFdmvUJjSCld3kwE2E49dUvevuUDSJsEuo=
|
||||
github.com/grafana/authlib/types v0.0.0-20250120145936-5f0e28e7a87c/go.mod h1:qYjSd1tmJiuVoSICp7Py9/zD54O9uQQA3wuM6Gg4DFM=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
|
||||
@@ -57,15 +57,6 @@ const oldAnnoKeyOriginPath = "grafana.app/originPath"
|
||||
const oldAnnoKeyOriginHash = "grafana.app/originHash"
|
||||
const oldAnnoKeyOriginTimestamp = "grafana.app/originTimestamp"
|
||||
|
||||
// annoKeyFullPath encodes the full path in folder resources
|
||||
// revisit keeping these folder-specific annotations once we have complete support for mode 1
|
||||
// Deprecated: this goes away when folders have a better solution
|
||||
const annoKeyFullPath = "grafana.app/fullPath"
|
||||
|
||||
// annoKeyFullPathUIDs encodes the full path in folder resources
|
||||
// Deprecated: this goes away when folders have a better solution
|
||||
const annoKeyFullPathUIDs = "grafana.app/fullPathUIDs"
|
||||
|
||||
// ResourceRepositoryInfo is encoded into kubernetes metadata annotations.
|
||||
// This value identifies indicates the state of the resource in its provisioning source when
|
||||
// the spec was last saved. Currently this is derived from the dashboards provisioning table.
|
||||
@@ -140,18 +131,6 @@ type GrafanaMetaAccessor interface {
|
||||
// NOTE the type must match the existing value, or an error will be thrown
|
||||
SetStatus(any) error
|
||||
|
||||
// Deprecated: this is a temporary hack for folders, it will be removed without notice soon
|
||||
GetFullPath() string
|
||||
|
||||
// Deprecated: this is a temporary hack for folders, it will be removed without notice soon
|
||||
SetFullPath(path string)
|
||||
|
||||
// Deprecated: this is a temporary hack for folders, it will be removed without notice soon
|
||||
GetFullPathUIDs() string
|
||||
|
||||
// Deprecated: this is a temporary hack for folders, it will be removed without notice soon
|
||||
SetFullPathUIDs(path string)
|
||||
|
||||
// Find a title in the object
|
||||
// This will reflect the object and try to get:
|
||||
// * spec.title
|
||||
@@ -706,26 +685,6 @@ func (m *grafanaMetaAccessor) SetStatus(s any) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func (m *grafanaMetaAccessor) GetFullPath() string {
|
||||
// nolint:staticcheck
|
||||
return m.get(annoKeyFullPath)
|
||||
}
|
||||
|
||||
func (m *grafanaMetaAccessor) SetFullPath(path string) {
|
||||
// nolint:staticcheck
|
||||
m.SetAnnotation(annoKeyFullPath, path)
|
||||
}
|
||||
|
||||
func (m *grafanaMetaAccessor) GetFullPathUIDs() string {
|
||||
// nolint:staticcheck
|
||||
return m.get(annoKeyFullPathUIDs)
|
||||
}
|
||||
|
||||
func (m *grafanaMetaAccessor) SetFullPathUIDs(path string) {
|
||||
// nolint:staticcheck
|
||||
m.SetAnnotation(annoKeyFullPathUIDs, path)
|
||||
}
|
||||
|
||||
func (m *grafanaMetaAccessor) FindTitle(defaultTitle string) string {
|
||||
// look for Spec.Title or Spec.Name
|
||||
spec := m.r.FieldByName("Spec")
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package migration
|
||||
|
||||
import "github.com/grafana/grafana/pkg/apis/dashboard/migration/schemaversion"
|
||||
|
||||
func Migrate(dash map[string]interface{}, targetVersion int) error {
|
||||
if dash == nil {
|
||||
dash = map[string]interface{}{}
|
||||
}
|
||||
inputVersion := schemaversion.GetSchemaVersion(dash)
|
||||
dash["schemaVersion"] = inputVersion
|
||||
|
||||
if inputVersion < schemaversion.MINIUM_VERSION {
|
||||
return schemaversion.NewMinimumVersionError(inputVersion)
|
||||
}
|
||||
|
||||
for nextVersion := inputVersion + 1; nextVersion <= targetVersion; nextVersion++ {
|
||||
if migration, ok := schemaversion.Migrations[nextVersion]; ok {
|
||||
if err := migration(dash); err != nil {
|
||||
return schemaversion.NewMigrationError("migration failed", inputVersion, nextVersion)
|
||||
}
|
||||
dash["schemaVersion"] = nextVersion
|
||||
}
|
||||
}
|
||||
|
||||
if schemaversion.GetSchemaVersion(dash) != targetVersion {
|
||||
return schemaversion.NewMigrationError("schema version not migrated to target version", inputVersion, targetVersion)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package migration_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apis/dashboard/migration"
|
||||
"github.com/grafana/grafana/pkg/apis/dashboard/migration/schemaversion"
|
||||
)
|
||||
|
||||
const INPUT_DIR = "testdata/input"
|
||||
const OUTPUT_DIR = "testdata/output"
|
||||
|
||||
func TestMigrate(t *testing.T) {
|
||||
files, err := os.ReadDir(INPUT_DIR)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("minimum version check", func(t *testing.T) {
|
||||
err := migration.Migrate(map[string]interface{}{
|
||||
"schemaVersion": schemaversion.MINIUM_VERSION - 1,
|
||||
}, schemaversion.MINIUM_VERSION)
|
||||
|
||||
var minVersionErr = schemaversion.NewMinimumVersionError(schemaversion.MINIUM_VERSION - 1)
|
||||
require.ErrorAs(t, err, &minVersionErr)
|
||||
})
|
||||
|
||||
for _, f := range files {
|
||||
if f.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
inputDash, inputVersion, name := load(t, filepath.Join(INPUT_DIR, f.Name()))
|
||||
|
||||
t.Run("input check "+f.Name(), func(t *testing.T) {
|
||||
// use input version as the target version to ensure there are no changes
|
||||
require.NoError(t, migration.Migrate(inputDash, inputVersion), "input check migration failed")
|
||||
outBytes, err := json.MarshalIndent(inputDash, "", " ")
|
||||
require.NoError(t, err, "failed to marshal migrated dashboard")
|
||||
// We can ignore gosec G304 here since it's a test
|
||||
// nolint:gosec
|
||||
expectedDash, err := os.ReadFile(filepath.Join(INPUT_DIR, f.Name()))
|
||||
require.NoError(t, err, "failed to read expected output file")
|
||||
require.JSONEq(t, string(expectedDash), string(outBytes), "%s input check did not match", f.Name())
|
||||
})
|
||||
|
||||
for targetVersion := range schemaversion.Migrations {
|
||||
testName := fmt.Sprintf("%s v%d to v%d", name, inputVersion, targetVersion)
|
||||
t.Run(testName, func(t *testing.T) {
|
||||
testMigration(t, f, targetVersion)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testMigration(t *testing.T, file fs.DirEntry, targetVersion int) {
|
||||
t.Helper()
|
||||
dash, inputVersion, name := load(t, filepath.Join(INPUT_DIR, file.Name()))
|
||||
require.NoError(t, migration.Migrate(dash, targetVersion), "%d migration failed", targetVersion)
|
||||
|
||||
outPath := filepath.Join(OUTPUT_DIR, fmt.Sprintf("%d.%s.%d.json", inputVersion, name, targetVersion))
|
||||
outBytes, err := json.MarshalIndent(dash, "", " ")
|
||||
require.NoError(t, err, "failed to marshal migrated dashboard")
|
||||
|
||||
if _, err := os.Stat(outPath); os.IsNotExist(err) {
|
||||
err = os.WriteFile(outPath, outBytes, 0644)
|
||||
require.NoError(t, err, "failed to write new output file", outPath)
|
||||
return
|
||||
}
|
||||
|
||||
// We can ignore gosec G304 here since it's a test
|
||||
// nolint:gosec
|
||||
existingBytes, err := os.ReadFile(outPath)
|
||||
require.NoError(t, err, "failed to read existing output file")
|
||||
require.JSONEq(t, string(existingBytes), string(outBytes), "%s did not match", outPath)
|
||||
}
|
||||
|
||||
func parseInputName(t *testing.T, name string) (int, string) {
|
||||
t.Helper()
|
||||
parts := strings.SplitN(filepath.Base(name), ".", 3)
|
||||
if len(parts) < 3 {
|
||||
t.Fatalf("invalid input filename: %s", name)
|
||||
}
|
||||
iv, err := strconv.Atoi(parts[0])
|
||||
require.NoError(t, err, "failed to parse input version")
|
||||
return iv, parts[1]
|
||||
}
|
||||
|
||||
func load(t *testing.T, path string) (dash map[string]interface{}, inputVersion int, name string) {
|
||||
// We can ignore gosec G304 here since it's a test
|
||||
// nolint:gosec
|
||||
inputBytes, err := os.ReadFile(path)
|
||||
require.NoError(t, err, "failed to read embedded input file")
|
||||
require.NoError(t, json.Unmarshal(inputBytes, &dash), "failed to unmarshal dashboard JSON")
|
||||
inputVersion, name = parseInputName(t, path)
|
||||
return dash, inputVersion, name
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package schemaversion
|
||||
|
||||
import "fmt"
|
||||
|
||||
var _ error = &MinimumVersionError{}
|
||||
var _ error = &MigrationError{}
|
||||
|
||||
// MinimumVersionError is an error that is returned when the schema version is below the minimum version.
|
||||
func NewMinimumVersionError(inputVersion int) *MinimumVersionError {
|
||||
return &MinimumVersionError{inputVersion: inputVersion}
|
||||
}
|
||||
|
||||
// MinimumVersionError is an error type for minimum version errors.
|
||||
type MinimumVersionError struct {
|
||||
inputVersion int
|
||||
}
|
||||
|
||||
func (e *MinimumVersionError) Error() string {
|
||||
return fmt.Errorf("input schema version is below minimum version. input: %d minimum: %d", e.inputVersion, MINIUM_VERSION).Error()
|
||||
}
|
||||
|
||||
// ErrMigrationFailed is an error that is returned when a migration fails.
|
||||
func NewMigrationError(msg string, currentVersion, targetVersion int) *MigrationError {
|
||||
return &MigrationError{
|
||||
msg: msg,
|
||||
targetVersion: targetVersion,
|
||||
currentVersion: currentVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// MigrationError is an error type for migration errors.
|
||||
type MigrationError struct {
|
||||
msg string
|
||||
targetVersion int
|
||||
currentVersion int
|
||||
}
|
||||
|
||||
func (e *MigrationError) Error() string {
|
||||
return fmt.Errorf("schema migration from version %d to %d failed: %v", e.currentVersion, e.targetVersion, e.msg).Error()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package schemaversion
|
||||
|
||||
import "strconv"
|
||||
|
||||
type SchemaVersionMigrationFunc func(map[string]interface{}) error
|
||||
|
||||
const (
|
||||
MINIUM_VERSION = 39
|
||||
LATEST_VERSION = 40
|
||||
)
|
||||
|
||||
var Migrations = map[int]SchemaVersionMigrationFunc{
|
||||
40: V40,
|
||||
}
|
||||
|
||||
func GetSchemaVersion(dash map[string]interface{}) int {
|
||||
if v, ok := dash["schemaVersion"]; ok {
|
||||
switch v := v.(type) {
|
||||
case int:
|
||||
return v
|
||||
case float64:
|
||||
return int(v)
|
||||
case string:
|
||||
if version, err := strconv.Atoi(v); err == nil {
|
||||
return version
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package schemaversion_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apis/dashboard/migration/schemaversion"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetSchemaVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dash map[string]interface{}
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "schemaVersion as int",
|
||||
dash: map[string]interface{}{
|
||||
"schemaVersion": 16,
|
||||
},
|
||||
expected: 16,
|
||||
},
|
||||
{
|
||||
name: "schemaVersion as float64",
|
||||
dash: map[string]interface{}{
|
||||
"schemaVersion": 40.2345,
|
||||
},
|
||||
expected: 40,
|
||||
},
|
||||
{
|
||||
name: "schemaVersion is not set",
|
||||
dash: map[string]interface{}{},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "schemaVersion as string int",
|
||||
dash: map[string]interface{}{
|
||||
"schemaVersion": "5",
|
||||
},
|
||||
expected: 5,
|
||||
},
|
||||
{
|
||||
name: "schemaVersion as invalid string",
|
||||
dash: map[string]interface{}{
|
||||
"schemaVersion": "foo",
|
||||
},
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := schemaversion.GetSchemaVersion(tt.dash)
|
||||
require.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package schemaversion
|
||||
|
||||
func V40(dash map[string]interface{}) error {
|
||||
dash["schemaVersion"] = int(40)
|
||||
if _, ok := dash["refresh"].(string); !ok {
|
||||
dash["refresh"] = ""
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package schemaversion_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apis/dashboard/migration/schemaversion"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestV40(t *testing.T) {
|
||||
tests := []migrationTestCase{
|
||||
{
|
||||
name: "refresh not set",
|
||||
input: map[string]interface{}{
|
||||
"title": "Test Dashboard",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "Test Dashboard",
|
||||
"schemaVersion": 40,
|
||||
"refresh": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "boolean refresh value is converted to an empty string",
|
||||
input: map[string]interface{}{
|
||||
"title": "Test Dashboard",
|
||||
"schemaVersion": 39,
|
||||
"refresh": true,
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "Test Dashboard",
|
||||
"schemaVersion": 40,
|
||||
"refresh": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "string refresh value is not converted",
|
||||
input: map[string]interface{}{
|
||||
"title": "Test Dashboard",
|
||||
"schemaVersion": 39,
|
||||
"refresh": "1m",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "Test Dashboard",
|
||||
"schemaVersion": 40,
|
||||
"refresh": "1m",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runMigrationTests(t, tests, schemaversion.V40)
|
||||
}
|
||||
|
||||
type migrationTestCase struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
expected map[string]interface{}
|
||||
}
|
||||
|
||||
func runMigrationTests(t *testing.T, testCases []migrationTestCase, migrationFunc schemaversion.SchemaVersionMigrationFunc) {
|
||||
t.Helper()
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := migrationFunc(tt.input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.expected, tt.input)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"barWidthFactor": 0.6,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"hideZeros": false,
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "11.5.0-81438",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana"
|
||||
},
|
||||
"queryType": "randomWalk",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Panel Title",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"preload": false,
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "utc",
|
||||
"title": "New dashboard",
|
||||
"version": 0,
|
||||
"weekStart": "",
|
||||
"refresh": true,
|
||||
"schemaVersion": 39
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations \u0026 Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"barWidthFactor": 0.6,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"hideZeros": false,
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "11.5.0-81438",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana"
|
||||
},
|
||||
"queryType": "randomWalk",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Panel Title",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"preload": false,
|
||||
"refresh": "",
|
||||
"schemaVersion": 40,
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "utc",
|
||||
"title": "New dashboard",
|
||||
"version": 0,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -1,26 +1,35 @@
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
conversion "k8s.io/apimachinery/pkg/conversion"
|
||||
klog "k8s.io/klog/v2"
|
||||
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apis/dashboard/migration"
|
||||
"github.com/grafana/grafana/pkg/apis/dashboard/migration/schemaversion"
|
||||
)
|
||||
|
||||
func Convert_v0alpha1_Unstructured_To_v1alpha1_DashboardSpec(in *common.Unstructured, out *DashboardSpec, s conversion.Scope) error {
|
||||
out.Unstructured = *in
|
||||
|
||||
t, ok := in.Object["title"]
|
||||
if !ok {
|
||||
return nil // skip setting the title if it's not in the unstructured object
|
||||
err := migration.Migrate(in.Object, schemaversion.LATEST_VERSION)
|
||||
if err != nil {
|
||||
minErr := &schemaversion.MinimumVersionError{}
|
||||
if errors.As(err, &minErr) {
|
||||
in.Object["__migrationError"] = err.Error()
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
title, ok := t.(string)
|
||||
out.Unstructured = *in
|
||||
|
||||
t, ok := in.Object["title"].(string)
|
||||
if !ok {
|
||||
klog.V(5).Infof("unstructured dashboard title field is not a string %v", t)
|
||||
return nil // skip setting the title if it's not a string in the unstructured object
|
||||
}
|
||||
out.Title = title
|
||||
out.Title = t
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
sdkjsoniter "github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/experimental/status"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
)
|
||||
|
||||
@@ -23,7 +24,7 @@ type Options struct {
|
||||
}
|
||||
|
||||
func rspErr(e error) backend.DataResponse {
|
||||
return backend.DataResponse{Error: e}
|
||||
return backend.DataResponse{Error: e, ErrorSource: status.SourceDownstream}
|
||||
}
|
||||
|
||||
// ReadPrometheusStyleResult will read results from a prometheus or loki server and return data frames
|
||||
@@ -39,7 +40,7 @@ func ReadPrometheusStyleResult(jIter *jsoniter.Iterator, opt Options) backend.Da
|
||||
l1Fields:
|
||||
for l1Field, err := iter.ReadObject(); ; l1Field, err = iter.ReadObject() {
|
||||
if err != nil {
|
||||
return rspErr(err)
|
||||
return rspErr(fmt.Errorf("response from prometheus couldn't be parsed. it is non-json: %w", err))
|
||||
}
|
||||
switch l1Field {
|
||||
case "status":
|
||||
|
||||
@@ -3,6 +3,7 @@ package querydata
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -28,28 +29,59 @@ func (s *QueryData) parseResponse(ctx context.Context, q *models.Query, res *htt
|
||||
ctx, endSpan := utils.StartTrace(ctx, s.tracer, "datasource.prometheus.parseResponse")
|
||||
defer endSpan()
|
||||
|
||||
iter := jsoniter.Parse(jsoniter.ConfigDefault, res.Body, 1024)
|
||||
r := converter.ReadPrometheusStyleResult(iter, converter.Options{Dataplane: true})
|
||||
r.Status = backend.Status(res.StatusCode)
|
||||
statusCode := res.StatusCode
|
||||
|
||||
// Add frame to attach metadata
|
||||
if len(r.Frames) == 0 && !q.ExemplarQuery {
|
||||
r.Frames = append(r.Frames, data.NewFrame(""))
|
||||
}
|
||||
switch {
|
||||
// Status codes that Prometheus might return
|
||||
// so we want to parse the response
|
||||
// https://prometheus.io/docs/prometheus/latest/querying/api/#format-overview
|
||||
case statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices,
|
||||
statusCode == http.StatusBadRequest,
|
||||
statusCode == http.StatusUnprocessableEntity,
|
||||
statusCode == http.StatusServiceUnavailable:
|
||||
|
||||
// The ExecutedQueryString can be viewed in QueryInspector in UI
|
||||
for i, frame := range r.Frames {
|
||||
addMetadataToMultiFrame(q, frame)
|
||||
if i == 0 {
|
||||
frame.Meta.ExecutedQueryString = executedQueryString(q)
|
||||
iter := jsoniter.Parse(jsoniter.ConfigDefault, res.Body, 1024)
|
||||
r := converter.ReadPrometheusStyleResult(iter, converter.Options{Dataplane: true})
|
||||
r.Status = backend.Status(res.StatusCode)
|
||||
|
||||
// Add frame to attach metadata
|
||||
if len(r.Frames) == 0 && !q.ExemplarQuery {
|
||||
r.Frames = append(r.Frames, data.NewFrame(""))
|
||||
}
|
||||
}
|
||||
|
||||
if r.Error == nil {
|
||||
r = s.processExemplars(ctx, q, r)
|
||||
}
|
||||
// The ExecutedQueryString can be viewed in QueryInspector in UI
|
||||
for i, frame := range r.Frames {
|
||||
addMetadataToMultiFrame(q, frame)
|
||||
if i == 0 {
|
||||
frame.Meta.ExecutedQueryString = executedQueryString(q)
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
if r.Error == nil {
|
||||
r = s.processExemplars(ctx, q, r)
|
||||
}
|
||||
|
||||
return r
|
||||
default:
|
||||
// Unknown status code. We don't want to parse the response.
|
||||
const maxBodySize = 1024
|
||||
lr := io.LimitReader(res.Body, maxBodySize)
|
||||
tb, _ := io.ReadAll(lr)
|
||||
|
||||
s.log.FromContext(ctx).Error("Unexpected response received", "status", statusCode, "body", tb)
|
||||
|
||||
errResp := backend.DataResponse{
|
||||
Error: fmt.Errorf("unexpected response with status code %d: %s", statusCode, tb),
|
||||
ErrorSource: backend.ErrorSourceFromHTTPStatus(statusCode),
|
||||
}
|
||||
|
||||
f := data.NewFrame("")
|
||||
addMetadataToMultiFrame(q, f)
|
||||
f.Meta.ExecutedQueryString = executedQueryString(q)
|
||||
errResp.Frames = append(errResp.Frames, f)
|
||||
|
||||
return errResp
|
||||
}
|
||||
}
|
||||
|
||||
func (s *QueryData) processExemplars(ctx context.Context, q *models.Query, dr backend.DataResponse) backend.DataResponse {
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/promlib/models"
|
||||
"github.com/grafana/grafana/pkg/promlib/querydata/exemplar"
|
||||
@@ -18,7 +20,7 @@ func TestQueryData_parseResponse(t *testing.T) {
|
||||
|
||||
t.Run("resultType is before result the field must parsed normally", func(t *testing.T) {
|
||||
resBody := `{"data":{"resultType":"vector", "result":[{"metric":{"__name__":"some_name","environment":"some_env","id":"some_id","instance":"some_instance:1234","job":"some_job","name":"another_name","region":"some_region"},"value":[1.1,"2"]}]},"status":"success"}`
|
||||
res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody))}
|
||||
res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody)), StatusCode: 200}
|
||||
result := qd.parseResponse(context.Background(), &models.Query{}, res)
|
||||
assert.Nil(t, result.Error)
|
||||
assert.Len(t, result.Frames, 1)
|
||||
@@ -26,7 +28,7 @@ func TestQueryData_parseResponse(t *testing.T) {
|
||||
|
||||
t.Run("resultType is after the result field must parsed normally", func(t *testing.T) {
|
||||
resBody := `{"data":{"result":[{"metric":{"__name__":"some_name","environment":"some_env","id":"some_id","instance":"some_instance:1234","job":"some_job","name":"another_name","region":"some_region"},"value":[1.1,"2"]}],"resultType":"vector"},"status":"success"}`
|
||||
res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody))}
|
||||
res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody)), StatusCode: 200}
|
||||
result := qd.parseResponse(context.Background(), &models.Query{}, res)
|
||||
assert.Nil(t, result.Error)
|
||||
assert.Len(t, result.Frames, 1)
|
||||
@@ -34,7 +36,7 @@ func TestQueryData_parseResponse(t *testing.T) {
|
||||
|
||||
t.Run("no resultType is existed in the data", func(t *testing.T) {
|
||||
resBody := `{"data":{"result":[{"metric":{"__name__":"some_name","environment":"some_env","id":"some_id","instance":"some_instance:1234","job":"some_job","name":"another_name","region":"some_region"},"value":[1.1,"2"]}]},"status":"success"}`
|
||||
res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody))}
|
||||
res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody)), StatusCode: 200}
|
||||
result := qd.parseResponse(context.Background(), &models.Query{}, res)
|
||||
assert.Error(t, result.Error)
|
||||
assert.Equal(t, result.Error.Error(), "no resultType found")
|
||||
@@ -42,7 +44,7 @@ func TestQueryData_parseResponse(t *testing.T) {
|
||||
|
||||
t.Run("resultType is set as empty string before result", func(t *testing.T) {
|
||||
resBody := `{"data":{"resultType":"", "result":[{"metric":{"__name__":"some_name","environment":"some_env","id":"some_id","instance":"some_instance:1234","job":"some_job","name":"another_name","region":"some_region"},"value":[1.1,"2"]}]},"status":"success"}`
|
||||
res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody))}
|
||||
res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody)), StatusCode: 200}
|
||||
result := qd.parseResponse(context.Background(), &models.Query{}, res)
|
||||
assert.Error(t, result.Error)
|
||||
assert.Equal(t, result.Error.Error(), "unknown result type: ")
|
||||
@@ -50,7 +52,7 @@ func TestQueryData_parseResponse(t *testing.T) {
|
||||
|
||||
t.Run("resultType is set as empty string after result", func(t *testing.T) {
|
||||
resBody := `{"data":{"result":[{"metric":{"__name__":"some_name","environment":"some_env","id":"some_id","instance":"some_instance:1234","job":"some_job","name":"another_name","region":"some_region"},"value":[1.1,"2"]}],"resultType":""},"status":"success"}`
|
||||
res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody))}
|
||||
res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody)), StatusCode: 200}
|
||||
result := qd.parseResponse(context.Background(), &models.Query{}, res)
|
||||
assert.Error(t, result.Error)
|
||||
assert.Equal(t, result.Error.Error(), "unknown result type: ")
|
||||
@@ -61,10 +63,47 @@ func TestAddMetadataToMultiFrame(t *testing.T) {
|
||||
t.Run("when you have native histogram result", func(t *testing.T) {
|
||||
qd := QueryData{exemplarSampler: exemplar.NewStandardDeviationSampler}
|
||||
resBody := `{"status":"success","data":{"resultType":"matrix","result":[{"metric":{"__name__":"rpc_durations_native_histogram_seconds","instance":"nativehisto:8080","job":"prometheus"},"histograms":[[1729529685,{"count":"7243102","sum":"72460202.93145595","buckets":[[0,"1.8340080864093422","2","10"],[0,"2","2.1810154653305154","68"]]}],[1729529700,{"count":"7243490","sum":"72464056.03309634","buckets":[[0,"1.8340080864093422","2","10"],[0,"2","2.1810154653305154","68"]]}],[1729529715,{"count":"7243880","sum":"72467935.35871512","buckets":[[0,"1.8340080864093422","2","10"],[0,"2","2.1810154653305154","68"]]}]]}]}}`
|
||||
res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody))}
|
||||
res := &http.Response{Body: io.NopCloser(bytes.NewBufferString(resBody)), StatusCode: 200}
|
||||
result := qd.parseResponse(context.Background(), &models.Query{}, res)
|
||||
assert.Nil(t, result.Error)
|
||||
assert.Len(t, result.Frames, 1)
|
||||
assert.Equal(t, "yMin", result.Frames[0].Fields[1].Name)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to create mock HTTP response.
|
||||
func createMockResponse(statusCode int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: statusCode,
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(body))),
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResponse_ErrorCases(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
body string
|
||||
}{
|
||||
{"500 Internal Server Error", http.StatusInternalServerError, `{"error":"internal server error"}`},
|
||||
{"404 Not Found", http.StatusNotFound, `{"error":"not found"}`},
|
||||
{"401 Unauthorized", http.StatusUnauthorized, `{"error":"unauthorized"}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := createMockResponse(tt.statusCode, tt.body)
|
||||
q := &models.Query{}
|
||||
qd := QueryData{exemplarSampler: exemplar.NewStandardDeviationSampler}
|
||||
qd.log = log.New()
|
||||
resp := qd.parseResponse(ctx, q, res)
|
||||
|
||||
require.Error(t, resp.Error)
|
||||
assert.Contains(t, resp.Error.Error(), "unexpected response")
|
||||
assert.Len(t, resp.Frames, 1)
|
||||
assert.NoError(t, res.Body.Close())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package folders
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -114,16 +113,6 @@ func UnstructuredToLegacyFolder(item unstructured.Unstructured, orgID int64) (*f
|
||||
// meta.GetUpdatedTimestamp() but it currently gets overwritten in prepareObjectForStorage().
|
||||
Updated: createdTime,
|
||||
OrgID: orgID,
|
||||
|
||||
// This will need to be restructured so the full path is looked up when saving
|
||||
// it can't be saved in the resource metadata because then everything must cascade
|
||||
// nolint:staticcheck
|
||||
Fullpath: meta.GetFullPath(),
|
||||
|
||||
// This will need to be restructured so the full path is looked up when saving
|
||||
// it can't be saved in the resource metadata because then everything must cascade
|
||||
// nolint:staticcheck
|
||||
FullpathUIDs: meta.GetFullPathUIDs(),
|
||||
}
|
||||
// CreatedBy needs to be returned separately because it's the user UID (string) but
|
||||
// folder.Folder expects user ID (int64).
|
||||
@@ -172,14 +161,6 @@ func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper)
|
||||
if v.ParentUID != "" {
|
||||
meta.SetFolder(v.ParentUID)
|
||||
}
|
||||
if v.Fullpath != "" {
|
||||
// nolint:staticcheck
|
||||
meta.SetFullPath(v.Fullpath)
|
||||
}
|
||||
if v.FullpathUIDs != "" {
|
||||
// nolint:staticcheck
|
||||
meta.SetFullPathUIDs(v.FullpathUIDs)
|
||||
}
|
||||
f.UID = gapiutil.CalculateClusterWideUID(f)
|
||||
return f, nil
|
||||
}
|
||||
@@ -203,22 +184,3 @@ func getCreated(meta utils.GrafanaMetaAccessor) (*time.Time, error) {
|
||||
created := meta.GetCreationTimestamp().Time
|
||||
return &created, nil
|
||||
}
|
||||
|
||||
func GetParentTitles(fullPath string) ([]string, error) {
|
||||
// Find all forward slashes which aren't escaped
|
||||
r, err := regexp.Compile(`[^\\](/)`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
indices := r.FindAllStringIndex(fullPath, -1)
|
||||
|
||||
var start int
|
||||
titles := []string{}
|
||||
for _, i := range indices {
|
||||
titles = append(titles, fullPath[start:i[0]+1])
|
||||
start = i[0] + 2
|
||||
}
|
||||
|
||||
titles = append(titles, fullPath[start:])
|
||||
return titles, nil
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package folders
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetParentTitles(t *testing.T) {
|
||||
path := "get\\/folder-folder-0/get\\/folder-folder-1/another"
|
||||
|
||||
titles, err := GetParentTitles(path)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, len(titles))
|
||||
require.Equal(t, "get\\/folder-folder-0", titles[0])
|
||||
require.Equal(t, "get\\/folder-folder-1", titles[1])
|
||||
require.Equal(t, "another", titles[2])
|
||||
}
|
||||
@@ -65,9 +65,7 @@ func RegisterAPIService(cfg *setting.Cfg,
|
||||
unified resource.ResourceClient,
|
||||
) *FolderAPIBuilder {
|
||||
if !featuremgmt.AnyEnabled(features,
|
||||
featuremgmt.FlagKubernetesFolders,
|
||||
featuremgmt.FlagKubernetesFoldersServiceV2,
|
||||
featuremgmt.FlagGrafanaAPIServerTestingWithExperimentalAPIs,
|
||||
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
|
||||
featuremgmt.FlagProvisioning) {
|
||||
return nil // skip registration unless opting into Kubernetes folders or unless we want to customize registration when testing
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"golang.org/x/net/context"
|
||||
"k8s.io/client-go/rest"
|
||||
|
||||
"github.com/grafana/authlib/claims"
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
@@ -67,7 +67,7 @@ type backgroundIdentities struct {
|
||||
}
|
||||
|
||||
func (o *backgroundIdentities) WorkerIdentity(ctx context.Context, namespace string) (identity.Requester, error) {
|
||||
info, err := claims.ParseNamespace(namespace)
|
||||
info, err := authlib.ParseNamespace(namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -150,7 +150,7 @@ func (o *backgroundIdentities) makeAdminUser(ctx context.Context, orgId int64) (
|
||||
UserID: found.ID,
|
||||
})
|
||||
|
||||
return claims.NewTypeID(claims.TypeUser, found.UID), err
|
||||
return authlib.NewTypeID(authlib.TypeUser, found.UID), err
|
||||
}
|
||||
|
||||
func (o *backgroundIdentities) verifyServiceAccount(ctx context.Context, orgId int64) (string, error) {
|
||||
@@ -191,7 +191,7 @@ func (o *backgroundIdentities) verifyServiceAccount(ctx context.Context, orgId i
|
||||
return "", err
|
||||
}
|
||||
|
||||
return claims.NewTypeID(claims.TypeServiceAccount, fmt.Sprintf("%d", serviceAccount.Id)), nil
|
||||
return authlib.NewTypeID(authlib.TypeServiceAccount, fmt.Sprintf("%d", serviceAccount.Id)), nil
|
||||
}
|
||||
|
||||
func (o *backgroundIdentities) RestConfigForNamespace(ctx context.Context, namespace string) (*rest.Config, error) {
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
|
||||
"github.com/grafana/authlib/claims"
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
||||
@@ -120,8 +120,7 @@ func RegisterAPIService(
|
||||
ghFactory github.ClientFactory,
|
||||
) (*ProvisioningAPIBuilder, error) {
|
||||
if !(features.IsEnabledGlobally(featuremgmt.FlagProvisioning) ||
|
||||
features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) ||
|
||||
features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerTestingWithExperimentalAPIs)) {
|
||||
features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs)) {
|
||||
return nil, nil // skip registration unless opting into experimental apis OR the feature specifically
|
||||
}
|
||||
|
||||
@@ -421,7 +420,7 @@ func (b *ProvisioningAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
|
||||
},
|
||||
},
|
||||
Handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := claims.From(r.Context())
|
||||
u, ok := authlib.AuthInfoFrom(r.Context())
|
||||
if !ok {
|
||||
w.WriteHeader(400)
|
||||
_, _ = w.Write([]byte("expected user"))
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/grafana/grafana/apps/advisor/pkg/apis"
|
||||
advisorv0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
|
||||
advisorapp "github.com/grafana/grafana/apps/advisor/pkg/app"
|
||||
"github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder/runner"
|
||||
)
|
||||
|
||||
@@ -13,11 +14,15 @@ type AdvisorAppProvider struct {
|
||||
app.Provider
|
||||
}
|
||||
|
||||
func RegisterApp() *AdvisorAppProvider {
|
||||
func RegisterApp(
|
||||
checkRegistry checkregistry.CheckService,
|
||||
) *AdvisorAppProvider {
|
||||
provider := &AdvisorAppProvider{}
|
||||
appCfg := &runner.AppBuilderConfig{
|
||||
OpenAPIDefGetter: advisorv0alpha1.GetOpenAPIDefinitions,
|
||||
ManagedKinds: advisorapp.GetKinds(),
|
||||
Authorizer: advisorapp.GetAuthorizer(),
|
||||
CustomConfig: any(checkRegistry),
|
||||
}
|
||||
provider.Provider = simple.NewAppProvider(apis.LocalManifest(), appCfg, advisorapp.New)
|
||||
return provider
|
||||
|
||||
@@ -3,6 +3,7 @@ package appregistry
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
|
||||
"github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/advisor"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/investigation"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/playlist"
|
||||
@@ -13,4 +14,6 @@ var WireSet = wire.NewSet(
|
||||
playlist.RegisterApp,
|
||||
investigation.RegisterApp,
|
||||
advisor.RegisterApp,
|
||||
checkregistry.ProvideService,
|
||||
wire.Bind(new(checkregistry.CheckService), new(*checkregistry.Service)),
|
||||
)
|
||||
@@ -49,7 +49,7 @@ func ProvideFolderPermissions(
|
||||
folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore)
|
||||
fService := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()),
|
||||
dashboardStore, folderStore, sqlStore, features,
|
||||
supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest())
|
||||
supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest())
|
||||
|
||||
acSvc := acimpl.ProvideOSSService(
|
||||
cfg, acdb.ProvideService(sqlStore), actionSets, localcache.ProvideService(),
|
||||
|
||||
@@ -49,9 +49,9 @@ func TestIntegrationAuthorize(t *testing.T) {
|
||||
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
|
||||
folderSvc := folderimpl.ProvideService(fStore, accesscontrolmock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()),
|
||||
dashStore, folderStore, sql, featuremgmt.WithFeatures(),
|
||||
supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest())
|
||||
supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest())
|
||||
dashSvc, err := dashboardsservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuremgmt.WithFeatures(), accesscontrolmock.NewMockedPermissionsService(),
|
||||
ac, folderSvc, fStore, nil, nil, nil, nil, quotatest.New(false, nil), nil)
|
||||
ac, folderSvc, fStore, nil, nil, nil, nil, quotatest.New(false, nil), nil, nil)
|
||||
require.NoError(t, err)
|
||||
dashSvc.RegisterDashboardPermissions(accesscontrolmock.NewMockedPermissionsService())
|
||||
|
||||
|
||||
@@ -61,9 +61,9 @@ func TestIntegrationAnnotationListingWithRBAC(t *testing.T) {
|
||||
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
|
||||
folderSvc := folderimpl.ProvideService(fStore, accesscontrolmock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()),
|
||||
dashStore, folderStore, sql, featuremgmt.WithFeatures(),
|
||||
supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest())
|
||||
supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest())
|
||||
dashSvc, err := dashboardsservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuremgmt.WithFeatures(), accesscontrolmock.NewMockedPermissionsService(),
|
||||
ac, folderSvc, fStore, nil, nil, nil, nil, quotatest.New(false, nil), nil)
|
||||
ac, folderSvc, fStore, nil, nil, nil, nil, quotatest.New(false, nil), nil, nil)
|
||||
require.NoError(t, err)
|
||||
dashSvc.RegisterDashboardPermissions(accesscontrolmock.NewMockedPermissionsService())
|
||||
repo := ProvideService(sql, cfg, features, tagService, tracing.InitializeTracerForTest(), ruleStore, dashSvc)
|
||||
@@ -243,9 +243,9 @@ func TestIntegrationAnnotationListingWithInheritedRBAC(t *testing.T) {
|
||||
fStore := folderimpl.ProvideStore(sql)
|
||||
folderStore := folderimpl.ProvideDashboardFolderStore(sql)
|
||||
folderSvc := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore,
|
||||
folderStore, sql, features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest())
|
||||
folderStore, sql, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest())
|
||||
dashSvc, err := dashboardsservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, features, accesscontrolmock.NewMockedPermissionsService(),
|
||||
ac, folderSvc, fStore, nil, nil, nil, nil, quotatest.New(false, nil), nil)
|
||||
ac, folderSvc, fStore, nil, nil, nil, nil, quotatest.New(false, nil), nil, nil)
|
||||
require.NoError(t, err)
|
||||
dashSvc.RegisterDashboardPermissions(accesscontrolmock.NewMockedPermissionsService())
|
||||
cfg.AnnotationMaximumTagsLength = 60
|
||||
|
||||
@@ -300,7 +300,7 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) {
|
||||
})
|
||||
|
||||
folderStore := folderimpl.ProvideStore(sqlStore)
|
||||
folderSvc := folderimpl.ProvideService(folderStore, mock.New(), bus.ProvideBus(tracer), dashboardWriteStore, folderimpl.ProvideDashboardFolderStore(sqlStore), sqlStore, features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest())
|
||||
folderSvc := folderimpl.ProvideService(folderStore, mock.New(), bus.ProvideBus(tracer), dashboardWriteStore, folderimpl.ProvideDashboardFolderStore(sqlStore), sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest())
|
||||
|
||||
parentUID := ""
|
||||
for i := 0; ; i++ {
|
||||
|
||||
@@ -902,7 +902,7 @@ func TestIntegrationFindDashboardsByTitle(t *testing.T) {
|
||||
folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore)
|
||||
fStore := folderimpl.ProvideStore(sqlStore)
|
||||
folderServiceWithFlagOn := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore,
|
||||
folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest())
|
||||
folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest())
|
||||
|
||||
user := &user.SignedInUser{
|
||||
OrgID: 1,
|
||||
@@ -1021,7 +1021,7 @@ func TestIntegrationFindDashboardsByFolder(t *testing.T) {
|
||||
fStore := folderimpl.ProvideStore(sqlStore)
|
||||
|
||||
folderServiceWithFlagOn := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore,
|
||||
folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest())
|
||||
folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest())
|
||||
|
||||
user := &user.SignedInUser{
|
||||
OrgID: 1,
|
||||
|
||||
@@ -360,6 +360,11 @@ type DeleteDashboardsInFolderRequest struct {
|
||||
OrgID int64
|
||||
}
|
||||
|
||||
type GetAllDashboardsInFolderRequest struct {
|
||||
FolderUIDs []string
|
||||
OrgID int64
|
||||
}
|
||||
|
||||
//
|
||||
// DASHBOARD ACL
|
||||
//
|
||||
|
||||
@@ -42,6 +42,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/publicdashboards"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/services/search/model"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity"
|
||||
@@ -56,6 +57,7 @@ var (
|
||||
provisionerPermissions = []accesscontrol.Permission{
|
||||
{Action: dashboards.ActionFoldersCreate, Scope: dashboards.ScopeFoldersAll},
|
||||
{Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersAll},
|
||||
{Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll},
|
||||
{Action: dashboards.ActionDashboardsCreate, Scope: dashboards.ScopeFoldersAll},
|
||||
{Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeFoldersAll},
|
||||
{Action: datasources.ActionRead, Scope: datasources.ScopeAll},
|
||||
@@ -70,19 +72,20 @@ var (
|
||||
)
|
||||
|
||||
type DashboardServiceImpl struct {
|
||||
cfg *setting.Cfg
|
||||
log log.Logger
|
||||
dashboardStore dashboards.Store
|
||||
folderStore folder.FolderStore
|
||||
folderService folder.Service
|
||||
userService user.Service
|
||||
orgService org.Service
|
||||
features featuremgmt.FeatureToggles
|
||||
folderPermissions accesscontrol.FolderPermissionsService
|
||||
dashboardPermissions accesscontrol.DashboardPermissionsService
|
||||
ac accesscontrol.AccessControl
|
||||
k8sclient client.K8sHandler
|
||||
metrics *dashboardsMetrics
|
||||
cfg *setting.Cfg
|
||||
log log.Logger
|
||||
dashboardStore dashboards.Store
|
||||
folderStore folder.FolderStore
|
||||
folderService folder.Service
|
||||
userService user.Service
|
||||
orgService org.Service
|
||||
features featuremgmt.FeatureToggles
|
||||
folderPermissions accesscontrol.FolderPermissionsService
|
||||
dashboardPermissions accesscontrol.DashboardPermissionsService
|
||||
ac accesscontrol.AccessControl
|
||||
k8sclient client.K8sHandler
|
||||
metrics *dashboardsMetrics
|
||||
publicDashboardService publicdashboards.ServiceWrapper
|
||||
|
||||
dashboardPermissionsReady chan struct{}
|
||||
}
|
||||
@@ -95,7 +98,7 @@ func ProvideDashboardServiceImpl(
|
||||
features featuremgmt.FeatureToggles, folderPermissionsService accesscontrol.FolderPermissionsService,
|
||||
ac accesscontrol.AccessControl, folderSvc folder.Service, fStore folder.Store, r prometheus.Registerer,
|
||||
restConfigProvider apiserver.RestConfigProvider, userService user.Service, unified resource.ResourceClient,
|
||||
quotaService quota.Service, orgService org.Service,
|
||||
quotaService quota.Service, orgService org.Service, publicDashboardService publicdashboards.ServiceWrapper,
|
||||
) (*DashboardServiceImpl, error) {
|
||||
k8sHandler := client.NewK8sHandler(request.GetNamespaceMapper(cfg), v0alpha1.DashboardResourceInfo.GroupVersionResource(), restConfigProvider, unified)
|
||||
|
||||
@@ -113,6 +116,7 @@ func ProvideDashboardServiceImpl(
|
||||
k8sclient: k8sHandler,
|
||||
metrics: newDashboardsMetrics(r),
|
||||
dashboardPermissionsReady: make(chan struct{}),
|
||||
publicDashboardService: publicDashboardService,
|
||||
}
|
||||
|
||||
defaultLimits, err := readQuotaConfig(cfg)
|
||||
@@ -611,13 +615,28 @@ func getGuardianForSavePermissionCheck(ctx context.Context, d *dashboards.Dashbo
|
||||
if newDashboard {
|
||||
// if it's a new dashboard/folder check the parent folder permissions
|
||||
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc()
|
||||
// nolint:staticcheck
|
||||
guard, err := guardian.New(ctx, d.FolderID, d.OrgID, user)
|
||||
guard, err := guardian.NewByFolder(ctx, &folder.Folder{
|
||||
ID: d.FolderID, // nolint:staticcheck
|
||||
OrgID: d.OrgID,
|
||||
}, d.OrgID, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return guard, nil
|
||||
}
|
||||
|
||||
if d.IsFolder {
|
||||
guard, err := guardian.NewByFolder(ctx, &folder.Folder{
|
||||
ID: d.ID, // nolint:staticcheck
|
||||
UID: d.UID,
|
||||
OrgID: d.OrgID,
|
||||
}, d.OrgID, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return guard, nil
|
||||
}
|
||||
|
||||
guard, err := guardian.NewByDashboard(ctx, d, d.OrgID, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -863,7 +882,13 @@ func (dr *DashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// cleanup things related to dashboards that are not stored in unistore yet
|
||||
err = dr.publicDashboardService.DeleteByDashboardUIDs(ctx, orgId, []string{dashboardUID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dr.dashboardStore.CleanupAfterDelete(ctx, cmd)
|
||||
}
|
||||
|
||||
@@ -878,6 +903,12 @@ func (dr *DashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId
|
||||
}
|
||||
}
|
||||
|
||||
// deletes all related public dashboard entities
|
||||
err := dr.publicDashboardService.DeleteByDashboardUIDs(ctx, orgId, []string{dashboardUID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dr.dashboardStore.DeleteDashboard(ctx, cmd)
|
||||
}
|
||||
|
||||
@@ -1072,11 +1103,26 @@ func (dr *DashboardServiceImpl) GetDashboards(ctx context.Context, query *dashbo
|
||||
query.OrgID = requester.GetOrgID()
|
||||
}
|
||||
|
||||
return dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
dashs, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
DashboardIds: query.DashboardIDs,
|
||||
OrgId: query.OrgID,
|
||||
DashboardUIDs: query.DashboardUIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// search only returns the metadata, need to get the dashboard.Data too
|
||||
results := make([]*dashboards.Dashboard, len(dashs))
|
||||
for i, d := range dashs {
|
||||
dash, err := dr.GetDashboard(ctx, &dashboards.GetDashboardQuery{OrgID: d.OrgID, UID: d.UID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results[i] = dash
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
return dr.dashboardStore.GetDashboards(ctx, query)
|
||||
@@ -1373,6 +1419,22 @@ func (dr *DashboardServiceImpl) DeleteInFolders(ctx context.Context, orgID int64
|
||||
return dr.dashboardStore.SoftDeleteDashboardsInFolders(ctx, orgID, folderUIDs)
|
||||
}
|
||||
|
||||
// We need a list of dashboard uids inside the folder to delete related public dashboards
|
||||
dashes, err := dr.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{SignedInUser: u, FolderUIDs: folderUIDs, OrgId: orgID})
|
||||
if err != nil {
|
||||
return folder.ErrInternal.Errorf("failed to fetch dashboards: %w", err)
|
||||
}
|
||||
|
||||
dashboardUIDs := make([]string, 0, len(dashes))
|
||||
for _, dashboard := range dashes {
|
||||
dashboardUIDs = append(dashboardUIDs, dashboard.UID)
|
||||
}
|
||||
|
||||
err = dr.publicDashboardService.DeleteByDashboardUIDs(ctx, orgID, dashboardUIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dr.dashboardStore.DeleteDashboardsInFolders(ctx, &dashboards.DeleteDashboardsInFolderRequest{FolderUIDs: folderUIDs, OrgID: orgID})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/folder/folderimpl"
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/publicdashboards"
|
||||
"github.com/grafana/grafana/pkg/services/quota/quotatest"
|
||||
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
|
||||
"github.com/grafana/grafana/pkg/services/tag/tagimpl"
|
||||
@@ -877,8 +879,9 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc
|
||||
folderPermissions := accesscontrolmock.NewMockedPermissionsService()
|
||||
folderPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil)
|
||||
tracer := tracing.InitializeTracerForTest()
|
||||
publicDashboardFakeService := publicdashboards.NewFakePublicDashboardServiceWrapper(t)
|
||||
folderStore2 := folderimpl.ProvideStore(sqlStore)
|
||||
folderService := folderimpl.ProvideService(folderStore2, actest.FakeAccessControl{ExpectedEvaluate: true}, bus.ProvideBus(tracer), dashboardStore, folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracer)
|
||||
folderService := folderimpl.ProvideService(folderStore2, actest.FakeAccessControl{ExpectedEvaluate: true}, bus.ProvideBus(tracer), dashboardStore, folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), publicDashboardFakeService, cfg, nil, tracer)
|
||||
dashboardPermissions := accesscontrolmock.NewMockedPermissionsService()
|
||||
dashboardService, err := ProvideDashboardServiceImpl(
|
||||
cfg, dashboardStore, folderStore,
|
||||
@@ -893,10 +896,11 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc
|
||||
nil,
|
||||
quotaService,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
dashboardService.RegisterDashboardPermissions(dashboardPermissions)
|
||||
require.NoError(t, err)
|
||||
guardian.InitAccessControlGuardian(cfg, ac, dashboardService)
|
||||
guardian.InitAccessControlGuardian(cfg, ac, dashboardService, folderService, log.NewNopLogger())
|
||||
|
||||
savedFolder := saveTestFolder(t, "Saved folder", testOrgID, sqlStore)
|
||||
savedDashInFolder := saveTestDashboard(t, "Saved dash in folder", testOrgID, savedFolder.UID, sqlStore)
|
||||
@@ -949,8 +953,9 @@ func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSt
|
||||
folderPermissions := accesscontrolmock.NewMockedPermissionsService()
|
||||
folderPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil)
|
||||
tracer := tracing.InitializeTracerForTest()
|
||||
publicDashboardFakeService := publicdashboards.NewFakePublicDashboardServiceWrapper(t)
|
||||
folderStore2 := folderimpl.ProvideStore(sqlStore)
|
||||
folderService := folderimpl.ProvideService(folderStore2, actest.FakeAccessControl{ExpectedEvaluate: true}, bus.ProvideBus(tracer), dashboardStore, folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracer)
|
||||
folderService := folderimpl.ProvideService(folderStore2, actest.FakeAccessControl{ExpectedEvaluate: true}, bus.ProvideBus(tracer), dashboardStore, folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), publicDashboardFakeService, cfg, nil, tracer)
|
||||
dashboardPermissions := accesscontrolmock.NewMockedPermissionsService()
|
||||
dashboardPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil)
|
||||
service, err := ProvideDashboardServiceImpl(
|
||||
@@ -966,6 +971,7 @@ func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSt
|
||||
nil,
|
||||
quotaService,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
service.RegisterDashboardPermissions(dashboardPermissions)
|
||||
@@ -984,8 +990,9 @@ func callSaveWithError(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSto
|
||||
require.NoError(t, err)
|
||||
folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore)
|
||||
tracer := tracing.InitializeTracerForTest()
|
||||
publicDashboardFakeService := publicdashboards.NewFakePublicDashboardServiceWrapper(t)
|
||||
folderStore2 := folderimpl.ProvideStore(sqlStore)
|
||||
folderService := folderimpl.ProvideService(folderStore2, actest.FakeAccessControl{ExpectedEvaluate: true}, bus.ProvideBus(tracer), dashboardStore, folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracer)
|
||||
folderService := folderimpl.ProvideService(folderStore2, actest.FakeAccessControl{ExpectedEvaluate: true}, bus.ProvideBus(tracer), dashboardStore, folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), publicDashboardFakeService, cfg, nil, tracer)
|
||||
service, err := ProvideDashboardServiceImpl(
|
||||
cfg, dashboardStore, folderStore,
|
||||
featuremgmt.WithFeatures(),
|
||||
@@ -999,6 +1006,7 @@ func callSaveWithError(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSto
|
||||
nil,
|
||||
quotaService,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
service.RegisterDashboardPermissions(accesscontrolmock.NewMockedPermissionsService())
|
||||
@@ -1036,8 +1044,9 @@ func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string
|
||||
dashboardPermissions := accesscontrolmock.NewMockedPermissionsService()
|
||||
dashboardPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil)
|
||||
tracer := tracing.InitializeTracerForTest()
|
||||
publicDashboardFakeService := publicdashboards.NewFakePublicDashboardServiceWrapper(t)
|
||||
folderStore2 := folderimpl.ProvideStore(sqlStore)
|
||||
folderService := folderimpl.ProvideService(folderStore2, actest.FakeAccessControl{ExpectedEvaluate: true}, bus.ProvideBus(tracer), dashboardStore, folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracer)
|
||||
folderService := folderimpl.ProvideService(folderStore2, actest.FakeAccessControl{ExpectedEvaluate: true}, bus.ProvideBus(tracer), dashboardStore, folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), publicDashboardFakeService, cfg, nil, tracer)
|
||||
service, err := ProvideDashboardServiceImpl(
|
||||
cfg, dashboardStore, folderStore,
|
||||
features,
|
||||
@@ -1051,6 +1060,7 @@ func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string
|
||||
nil,
|
||||
quotaService,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
service.RegisterDashboardPermissions(dashboardPermissions)
|
||||
@@ -1094,8 +1104,9 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.DB) *da
|
||||
folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore)
|
||||
folderPermissions := accesscontrolmock.NewMockedPermissionsService()
|
||||
tracer := tracing.InitializeTracerForTest()
|
||||
publicDashboardFakeService := publicdashboards.NewFakePublicDashboardServiceWrapper(t)
|
||||
folderStore2 := folderimpl.ProvideStore(sqlStore)
|
||||
folderService := folderimpl.ProvideService(folderStore2, actest.FakeAccessControl{ExpectedEvaluate: true}, bus.ProvideBus(tracer), dashboardStore, folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracer)
|
||||
folderService := folderimpl.ProvideService(folderStore2, actest.FakeAccessControl{ExpectedEvaluate: true}, bus.ProvideBus(tracer), dashboardStore, folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), publicDashboardFakeService, cfg, nil, tracer)
|
||||
folderPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil)
|
||||
service, err := ProvideDashboardServiceImpl(
|
||||
cfg, dashboardStore, folderStore,
|
||||
@@ -1110,6 +1121,7 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.DB) *da
|
||||
nil,
|
||||
quotaService,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
service.RegisterDashboardPermissions(accesscontrolmock.NewMockedPermissionsService())
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/org/orgtest"
|
||||
"github.com/grafana/grafana/pkg/services/publicdashboards"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/services/search/model"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
@@ -35,15 +36,17 @@ import (
|
||||
func TestDashboardService(t *testing.T) {
|
||||
t.Run("Dashboard service tests", func(t *testing.T) {
|
||||
fakeStore := dashboards.FakeDashboardStore{}
|
||||
fakePublicDashboardService := publicdashboards.NewFakePublicDashboardServiceWrapper(t)
|
||||
defer fakeStore.AssertExpectations(t)
|
||||
|
||||
folderSvc := foldertest.NewFakeService()
|
||||
service := &DashboardServiceImpl{
|
||||
cfg: setting.NewCfg(),
|
||||
log: log.New("test.logger"),
|
||||
dashboardStore: &fakeStore,
|
||||
folderService: folderSvc,
|
||||
features: featuremgmt.WithFeatures(),
|
||||
cfg: setting.NewCfg(),
|
||||
log: log.New("test.logger"),
|
||||
dashboardStore: &fakeStore,
|
||||
folderService: folderSvc,
|
||||
features: featuremgmt.WithFeatures(),
|
||||
publicDashboardService: fakePublicDashboardService,
|
||||
}
|
||||
folderStore := foldertest.FakeFolderStore{}
|
||||
folderStore.On("GetFolderByUID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).Return(nil, dashboards.ErrFolderNotFound).Once()
|
||||
@@ -197,6 +200,7 @@ func TestDashboardService(t *testing.T) {
|
||||
t.Run("DeleteProvisionedDashboard should delete it", func(t *testing.T) {
|
||||
args := &dashboards.DeleteDashboardCommand{OrgID: 1, ID: 1}
|
||||
fakeStore.On("DeleteDashboard", mock.Anything, args).Return(nil).Once()
|
||||
fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
|
||||
err := service.DeleteProvisionedDashboard(context.Background(), 1, 1)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
@@ -212,6 +216,7 @@ func TestDashboardService(t *testing.T) {
|
||||
t.Run("DeleteProvisionedDashboard should delete the dashboard", func(t *testing.T) {
|
||||
args := &dashboards.DeleteDashboardCommand{OrgID: 1, ID: 1}
|
||||
fakeStore.On("DeleteDashboard", mock.Anything, args).Return(nil).Once()
|
||||
fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
|
||||
err := service.DeleteProvisionedDashboard(context.Background(), 1, 1)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
@@ -220,6 +225,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()
|
||||
fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
|
||||
err := service.DeleteDashboard(context.Background(), 1, "", 1)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
@@ -240,6 +246,8 @@ func TestDashboardService(t *testing.T) {
|
||||
t.Run("Delete dashboards in folder", func(t *testing.T) {
|
||||
args := &dashboards.DeleteDashboardsInFolderRequest{OrgID: 1, FolderUIDs: []string{"uid"}}
|
||||
fakeStore.On("DeleteDashboardsInFolders", mock.Anything, args).Return(nil).Once()
|
||||
fakeStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{}, nil).Once()
|
||||
fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
|
||||
err := service.DeleteInFolders(context.Background(), 1, []string{"uid"}, nil)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
@@ -775,6 +783,7 @@ func TestGetProvisionedDashboardDataByDashboardUID(t *testing.T) {
|
||||
|
||||
func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
|
||||
fakeStore := dashboards.FakeDashboardStore{}
|
||||
fakePublicDashboardService := publicdashboards.NewFakePublicDashboardServiceWrapper(t)
|
||||
defer fakeStore.AssertExpectations(t)
|
||||
service := &DashboardServiceImpl{
|
||||
cfg: setting.NewCfg(),
|
||||
@@ -782,6 +791,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
|
||||
orgService: &orgtest.FakeOrgService{
|
||||
ExpectedOrgs: []*org.OrgDTO{{ID: 1}, {ID: 2}},
|
||||
},
|
||||
publicDashboardService: fakePublicDashboardService,
|
||||
}
|
||||
|
||||
t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) {
|
||||
@@ -801,6 +811,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
|
||||
k8sCliMock.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
fakeStore.On("CleanupAfterDelete", mock.Anything, &dashboards.DeleteDashboardCommand{UID: "uid", OrgID: 1}).Return(nil).Once()
|
||||
fakeStore.On("CleanupAfterDelete", mock.Anything, &dashboards.DeleteDashboardCommand{UID: "uid3", OrgID: 2}).Return(nil).Once()
|
||||
fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"name": "uid",
|
||||
@@ -1201,16 +1212,20 @@ func TestSaveDashboard(t *testing.T) {
|
||||
|
||||
func TestDeleteDashboard(t *testing.T) {
|
||||
fakeStore := dashboards.FakeDashboardStore{}
|
||||
fakePublicDashboardService := publicdashboards.NewFakePublicDashboardServiceWrapper(t)
|
||||
defer fakeStore.AssertExpectations(t)
|
||||
service := &DashboardServiceImpl{
|
||||
cfg: setting.NewCfg(),
|
||||
dashboardStore: &fakeStore,
|
||||
cfg: setting.NewCfg(),
|
||||
dashboardStore: &fakeStore,
|
||||
publicDashboardService: fakePublicDashboardService,
|
||||
}
|
||||
|
||||
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()
|
||||
fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
|
||||
|
||||
err := service.DeleteDashboard(context.Background(), 1, "uid", 1)
|
||||
require.NoError(t, err)
|
||||
fakeStore.AssertExpectations(t)
|
||||
@@ -1220,6 +1235,7 @@ func TestDeleteDashboard(t *testing.T) {
|
||||
ctx, k8sCliMock := setupK8sDashboardTests(service)
|
||||
k8sCliMock.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
|
||||
fakeStore.On("CleanupAfterDelete", mock.Anything, mock.Anything).Return(nil).Once()
|
||||
fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
|
||||
|
||||
err := service.DeleteDashboard(ctx, 1, "uid", 1)
|
||||
require.NoError(t, err)
|
||||
@@ -1230,6 +1246,7 @@ func TestDeleteDashboard(t *testing.T) {
|
||||
ctx, k8sCliMock := setupK8sDashboardTests(service)
|
||||
k8sCliMock.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
|
||||
fakeStore.On("CleanupAfterDelete", mock.Anything, mock.Anything).Return(nil).Once()
|
||||
fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
|
||||
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
@@ -1414,14 +1431,32 @@ func TestGetDashboards(t *testing.T) {
|
||||
Slug: "dashboard-1",
|
||||
OrgID: 1,
|
||||
Title: "Dashboard 1",
|
||||
Data: simplejson.NewFromAny(map[string]any{"title": "Dashboard 1", "uid": "uid1"}),
|
||||
},
|
||||
{
|
||||
UID: "uid2",
|
||||
Slug: "dashboard-2",
|
||||
OrgID: 1,
|
||||
Title: "Dashboard 2",
|
||||
Data: simplejson.NewFromAny(map[string]any{"title": "Dashboard 2", "uid": "uid2"}),
|
||||
},
|
||||
}
|
||||
uid1Unstructured := &unstructured.Unstructured{Object: map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"name": "uid1",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Dashboard 1",
|
||||
},
|
||||
}}
|
||||
uid2Unstructured := &unstructured.Unstructured{Object: map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"name": "uid2",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Dashboard 2",
|
||||
},
|
||||
}}
|
||||
queryByIDs := &dashboards.GetDashboardsQuery{
|
||||
DashboardIDs: []int64{1, 2},
|
||||
OrgID: 1,
|
||||
@@ -1450,6 +1485,8 @@ func TestGetDashboards(t *testing.T) {
|
||||
|
||||
t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) {
|
||||
ctx, k8sCliMock := setupK8sDashboardTests(service)
|
||||
k8sCliMock.On("Get", mock.Anything, "uid1", mock.Anything, mock.Anything).Return(uid1Unstructured, nil)
|
||||
k8sCliMock.On("Get", mock.Anything, "uid2", mock.Anything, mock.Anything).Return(uid2Unstructured, nil)
|
||||
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
|
||||
@@ -18,6 +18,24 @@ type FakeDashboardStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// CleanupAfterDelete provides a mock function with given fields: ctx, cmd
|
||||
func (_m *FakeDashboardStore) CleanupAfterDelete(ctx context.Context, cmd *DeleteDashboardCommand) error {
|
||||
ret := _m.Called(ctx, cmd)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CleanupAfterDelete")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *DeleteDashboardCommand) error); ok {
|
||||
r0 = rf(ctx, cmd)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Count provides a mock function with given fields: _a0, _a1
|
||||
func (_m *FakeDashboardStore) Count(_a0 context.Context, _a1 *quota.ScopeParameters) (*quota.Map, error) {
|
||||
ret := _m.Called(_a0, _a1)
|
||||
@@ -106,6 +124,24 @@ func (_m *FakeDashboardStore) CountDashboardsInFolders(ctx context.Context, requ
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// DeleteAllDashboards provides a mock function with given fields: ctx, orgID
|
||||
func (_m *FakeDashboardStore) DeleteAllDashboards(ctx context.Context, orgID int64) error {
|
||||
ret := _m.Called(ctx, orgID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for DeleteAllDashboards")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok {
|
||||
r0 = rf(ctx, orgID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// DeleteDashboard provides a mock function with given fields: ctx, cmd
|
||||
func (_m *FakeDashboardStore) DeleteDashboard(ctx context.Context, cmd *DeleteDashboardCommand) error {
|
||||
ret := _m.Called(ctx, cmd)
|
||||
@@ -124,42 +160,6 @@ func (_m *FakeDashboardStore) DeleteDashboard(ctx context.Context, cmd *DeleteDa
|
||||
return r0
|
||||
}
|
||||
|
||||
// CleanupAfterDelete provides a mock function with given fields: ctx, cmd
|
||||
func (_m *FakeDashboardStore) CleanupAfterDelete(ctx context.Context, cmd *DeleteDashboardCommand) error {
|
||||
ret := _m.Called(ctx, cmd)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CleanupAfterDelete")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *DeleteDashboardCommand) error); ok {
|
||||
r0 = rf(ctx, cmd)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// DeleteAllDashboards provides a mock function with given fields: ctx, orgID
|
||||
func (_m *FakeDashboardStore) DeleteAllDashboards(ctx context.Context, orgID int64) error {
|
||||
ret := _m.Called(ctx, 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) error); ok {
|
||||
r0 = rf(ctx, orgID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// DeleteDashboardsInFolders provides a mock function with given fields: ctx, request
|
||||
func (_m *FakeDashboardStore) DeleteDashboardsInFolders(ctx context.Context, request *DeleteDashboardsInFolderRequest) error {
|
||||
ret := _m.Called(ctx, request)
|
||||
@@ -256,7 +256,7 @@ func (_m *FakeDashboardStore) GetAllDashboards(ctx context.Context) ([]*Dashboar
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetAllDashboardsByOrgId provides a mock function with given fields: ctx
|
||||
// GetAllDashboardsByOrgId provides a mock function with given fields: ctx, orgID
|
||||
func (_m *FakeDashboardStore) GetAllDashboardsByOrgId(ctx context.Context, orgID int64) ([]*Dashboard, error) {
|
||||
ret := _m.Called(ctx, orgID)
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ func TestValidateDashboardExists(t *testing.T) {
|
||||
nil,
|
||||
quotatest.New(false, nil),
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
s := ProvideService(dsStore, secretsService, dashSvc)
|
||||
|
||||
@@ -708,24 +708,12 @@ var (
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaAppPlatformSquad,
|
||||
},
|
||||
{
|
||||
Name: "kubernetesFolders",
|
||||
Description: "Use the kubernetes API in the frontend for folders, and route /api/folders requests to k8s",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaSearchAndStorageSquad,
|
||||
},
|
||||
{
|
||||
Name: "kubernetesFoldersServiceV2",
|
||||
Description: "Use the Folders Service V2, and route Folder Service requests to k8s",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaSearchAndStorageSquad,
|
||||
},
|
||||
{
|
||||
Name: "grafanaAPIServerTestingWithExperimentalAPIs",
|
||||
Description: "Facilitate integration testing of experimental APIs",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaSearchAndStorageSquad,
|
||||
},
|
||||
{
|
||||
Name: "datasourceQueryTypes",
|
||||
Description: "Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus)",
|
||||
|
||||
@@ -93,9 +93,7 @@ kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,false,true,
|
||||
kubernetesDashboards,experimental,@grafana/grafana-app-platform-squad,false,false,true
|
||||
kubernetesCliDashboards,experimental,@grafana/grafana-app-platform-squad,false,false,false
|
||||
kubernetesRestore,experimental,@grafana/grafana-app-platform-squad,false,false,false
|
||||
kubernetesFolders,experimental,@grafana/search-and-storage,false,false,false
|
||||
kubernetesFoldersServiceV2,experimental,@grafana/search-and-storage,false,false,false
|
||||
grafanaAPIServerTestingWithExperimentalAPIs,experimental,@grafana/search-and-storage,false,false,false
|
||||
datasourceQueryTypes,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||
queryService,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||
queryServiceRewrite,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||
|
||||
|
@@ -383,18 +383,10 @@ const (
|
||||
// Allow restoring objects in k8s
|
||||
FlagKubernetesRestore = "kubernetesRestore"
|
||||
|
||||
// FlagKubernetesFolders
|
||||
// Use the kubernetes API in the frontend for folders, and route /api/folders requests to k8s
|
||||
FlagKubernetesFolders = "kubernetesFolders"
|
||||
|
||||
// FlagKubernetesFoldersServiceV2
|
||||
// Use the Folders Service V2, and route Folder Service requests to k8s
|
||||
FlagKubernetesFoldersServiceV2 = "kubernetesFoldersServiceV2"
|
||||
|
||||
// FlagGrafanaAPIServerTestingWithExperimentalAPIs
|
||||
// Facilitate integration testing of experimental APIs
|
||||
FlagGrafanaAPIServerTestingWithExperimentalAPIs = "grafanaAPIServerTestingWithExperimentalAPIs"
|
||||
|
||||
// FlagDatasourceQueryTypes
|
||||
// Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus)
|
||||
FlagDatasourceQueryTypes = "datasourceQueryTypes"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"metadata": {
|
||||
"name": "ABTestFeatureToggleA",
|
||||
"resourceVersion": "1736782112674",
|
||||
"creationTimestamp": "2025-01-13T15:28:32Z"
|
||||
"creationTimestamp": "2025-01-13T21:13:13Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Test feature toggle to see how cohorts could be set up AB testing",
|
||||
@@ -21,7 +21,7 @@
|
||||
"metadata": {
|
||||
"name": "ABTestFeatureToggleB",
|
||||
"resourceVersion": "1736782112674",
|
||||
"creationTimestamp": "2025-01-13T15:28:32Z"
|
||||
"creationTimestamp": "2025-01-13T21:13:13Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Test feature toggle to see how cohorts could be set up AB testing",
|
||||
@@ -277,7 +277,7 @@
|
||||
"metadata": {
|
||||
"name": "alertingNotificationsStepMode",
|
||||
"resourceVersion": "1737362059637",
|
||||
"creationTimestamp": "2024-11-06T09:35:49Z",
|
||||
"creationTimestamp": "2024-11-22T11:07:45Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-01-20 08:34:19.63725 +0000 UTC"
|
||||
}
|
||||
@@ -294,7 +294,7 @@
|
||||
"metadata": {
|
||||
"name": "alertingPrometheusRulesPrimary",
|
||||
"resourceVersion": "1727332930692",
|
||||
"creationTimestamp": "2024-09-09T13:56:47Z",
|
||||
"creationTimestamp": "2024-09-27T12:27:16Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-09-26 06:42:10.692959 +0000 UTC"
|
||||
}
|
||||
@@ -371,7 +371,7 @@
|
||||
"metadata": {
|
||||
"name": "alertingUIOptimizeReducer",
|
||||
"resourceVersion": "1731923458730",
|
||||
"creationTimestamp": "2024-11-18T09:06:02Z",
|
||||
"creationTimestamp": "2024-11-18T10:59:00Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-11-18 09:50:58.730825 +0000 UTC"
|
||||
}
|
||||
@@ -458,7 +458,7 @@
|
||||
"name": "appPlatformAccessTokens",
|
||||
"resourceVersion": "1725549369316",
|
||||
"creationTimestamp": "2024-09-05T16:18:44Z",
|
||||
"deletionTimestamp": "2024-10-14T13:14:46Z"
|
||||
"deletionTimestamp": "2024-10-14T10:47:18Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the use of access tokens for the App Platform",
|
||||
@@ -472,7 +472,7 @@
|
||||
"metadata": {
|
||||
"name": "appPlatformGrpcClientAuth",
|
||||
"resourceVersion": "1728662061076",
|
||||
"creationTimestamp": "2024-10-11T15:54:21Z"
|
||||
"creationTimestamp": "2024-10-14T10:47:18Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the gRPC client to authenticate with the App Platform by using ID \u0026 access tokens",
|
||||
@@ -608,7 +608,7 @@
|
||||
"name": "autoMigrateXYChartPanel",
|
||||
"resourceVersion": "1722537244598",
|
||||
"creationTimestamp": "2024-03-22T15:44:37Z",
|
||||
"deletionTimestamp": "2024-11-14T01:17:06Z",
|
||||
"deletionTimestamp": "2024-11-14T16:36:18Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-08-01 18:34:04.598082 +0000 UTC"
|
||||
}
|
||||
@@ -684,7 +684,7 @@
|
||||
"metadata": {
|
||||
"name": "azureMonitorDisableLogLimit",
|
||||
"resourceVersion": "1727698096407",
|
||||
"creationTimestamp": "2024-09-30T11:51:51Z",
|
||||
"creationTimestamp": "2024-10-24T13:32:09Z",
|
||||
"deletionTimestamp": "2024-10-22T09:44:12Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-09-30 12:08:16.407109 +0000 UTC"
|
||||
@@ -701,7 +701,7 @@
|
||||
"metadata": {
|
||||
"name": "azureMonitorEnableUserAuth",
|
||||
"resourceVersion": "1732189410576",
|
||||
"creationTimestamp": "2024-11-21T11:42:29Z",
|
||||
"creationTimestamp": "2024-11-27T14:01:54Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-11-21 11:43:30.576196 +0000 UTC"
|
||||
}
|
||||
@@ -902,7 +902,7 @@
|
||||
"name": "cloudwatchMetricInsightsCrossAccount",
|
||||
"resourceVersion": "1729265619643",
|
||||
"creationTimestamp": "2024-07-02T10:34:12Z",
|
||||
"deletionTimestamp": "2025-01-10T15:06:19Z",
|
||||
"deletionTimestamp": "2025-01-10T22:23:23Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-10-18 15:33:39.643165 +0000 UTC"
|
||||
}
|
||||
@@ -950,7 +950,7 @@
|
||||
"metadata": {
|
||||
"name": "crashDetection",
|
||||
"resourceVersion": "1730381712885",
|
||||
"creationTimestamp": "2024-10-31T13:35:12Z"
|
||||
"creationTimestamp": "2024-11-12T15:07:27Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables browser crash detection reporting to Faro.",
|
||||
@@ -963,7 +963,7 @@
|
||||
"metadata": {
|
||||
"name": "dashboardNewLayouts",
|
||||
"resourceVersion": "1729671312626",
|
||||
"creationTimestamp": "2024-10-16T08:44:05Z",
|
||||
"creationTimestamp": "2024-10-23T08:55:45Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-10-23 08:15:12.626632 +0000 UTC"
|
||||
}
|
||||
@@ -997,7 +997,7 @@
|
||||
"name": "dashboardRestoreUI",
|
||||
"resourceVersion": "1720021873452",
|
||||
"creationTimestamp": "2024-06-25T14:43:13Z",
|
||||
"deletionTimestamp": "2024-10-08T14:24:51Z",
|
||||
"deletionTimestamp": "2024-10-11T08:29:58Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-07-03 15:51:13.452477 +0000 UTC"
|
||||
}
|
||||
@@ -1064,8 +1064,8 @@
|
||||
"metadata": {
|
||||
"name": "dashboardSchemaV2",
|
||||
"resourceVersion": "1730192092473",
|
||||
"creationTimestamp": "2024-10-29T08:54:52Z",
|
||||
"deletionTimestamp": "2024-12-19T12:03:44Z"
|
||||
"creationTimestamp": "2024-10-29T10:35:18Z",
|
||||
"deletionTimestamp": "2024-12-19T12:28:20Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the new dashboard schema version 2, implementing changes necessary for dynamic dashboards and dashboards as code.",
|
||||
@@ -1159,7 +1159,7 @@
|
||||
"metadata": {
|
||||
"name": "datasourceConnectionsTab",
|
||||
"resourceVersion": "1737049826022",
|
||||
"creationTimestamp": "2025-01-16T17:36:09Z",
|
||||
"creationTimestamp": "2025-01-21T17:39:48Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-01-16 17:50:26.022636488 +0000 UTC"
|
||||
}
|
||||
@@ -1331,7 +1331,7 @@
|
||||
"metadata": {
|
||||
"name": "elasticsearchCrossClusterSearch",
|
||||
"resourceVersion": "1733848475752",
|
||||
"creationTimestamp": "2024-12-09T13:53:38Z",
|
||||
"creationTimestamp": "2024-12-12T22:20:04Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-12-10 16:34:35.752111 +0000 UTC"
|
||||
}
|
||||
@@ -1346,7 +1346,7 @@
|
||||
"metadata": {
|
||||
"name": "elasticsearchImprovedParsing",
|
||||
"resourceVersion": "1736808262603",
|
||||
"creationTimestamp": "2025-01-13T20:32:35Z",
|
||||
"creationTimestamp": "2025-01-15T17:05:54Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-01-13 22:44:22.603729 +0000 UTC"
|
||||
}
|
||||
@@ -1374,7 +1374,7 @@
|
||||
"metadata": {
|
||||
"name": "enableExtensionsAdminPage",
|
||||
"resourceVersion": "1730819353237",
|
||||
"creationTimestamp": "2024-11-05T09:18:42Z",
|
||||
"creationTimestamp": "2024-11-05T15:55:10Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-11-05 15:09:13.237578 +0000 UTC"
|
||||
}
|
||||
@@ -1404,7 +1404,7 @@
|
||||
"metadata": {
|
||||
"name": "enableSCIM",
|
||||
"resourceVersion": "1730980484343",
|
||||
"creationTimestamp": "2024-11-07T11:54:44Z"
|
||||
"creationTimestamp": "2024-11-07T14:38:46Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables SCIM support for user and group management",
|
||||
@@ -1416,7 +1416,7 @@
|
||||
"metadata": {
|
||||
"name": "enableScopesInMetricsExplore",
|
||||
"resourceVersion": "1729765731452",
|
||||
"creationTimestamp": "2024-10-24T10:28:51Z"
|
||||
"creationTimestamp": "2024-11-06T13:11:33Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the scopes usage in Metrics Explore",
|
||||
@@ -1501,7 +1501,7 @@
|
||||
"metadata": {
|
||||
"name": "exploreMetricsRelatedLogs",
|
||||
"resourceVersion": "1730125602673",
|
||||
"creationTimestamp": "2024-10-28T14:26:42Z"
|
||||
"creationTimestamp": "2024-11-05T16:28:43Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Display Related Logs in Explore Metrics",
|
||||
@@ -1635,7 +1635,7 @@
|
||||
"metadata": {
|
||||
"name": "feedbackButton",
|
||||
"resourceVersion": "1733158016122",
|
||||
"creationTimestamp": "2024-12-02T16:46:56Z"
|
||||
"creationTimestamp": "2024-12-02T17:08:15Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables a button to send feedback from the Grafana UI",
|
||||
@@ -1734,7 +1734,8 @@
|
||||
"metadata": {
|
||||
"name": "grafanaAPIServerTestingWithExperimentalAPIs",
|
||||
"resourceVersion": "1727945615419",
|
||||
"creationTimestamp": "2024-10-03T08:53:35Z"
|
||||
"creationTimestamp": "2024-10-03T10:11:40Z",
|
||||
"deletionTimestamp": "2025-01-22T20:53:53Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Facilitate integration testing of experimental APIs",
|
||||
@@ -1763,7 +1764,7 @@
|
||||
"metadata": {
|
||||
"name": "grafanaAdvisor",
|
||||
"resourceVersion": "1737365459765",
|
||||
"creationTimestamp": "2025-01-20T09:30:59Z"
|
||||
"creationTimestamp": "2025-01-20T10:08:00Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables Advisor app",
|
||||
@@ -1897,7 +1898,7 @@
|
||||
"metadata": {
|
||||
"name": "improvedExternalSessionHandlingSAML",
|
||||
"resourceVersion": "1737370880023",
|
||||
"creationTimestamp": "2025-01-09T16:33:07Z",
|
||||
"creationTimestamp": "2025-01-09T17:02:49Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-01-20 11:01:20.02358 +0000 UTC"
|
||||
}
|
||||
@@ -1973,7 +1974,7 @@
|
||||
"metadata": {
|
||||
"name": "investigationsBackend",
|
||||
"resourceVersion": "1734447689720",
|
||||
"creationTimestamp": "2024-12-17T15:01:29Z"
|
||||
"creationTimestamp": "2024-12-18T08:31:03Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable the investigations backend API",
|
||||
@@ -2000,7 +2001,7 @@
|
||||
"metadata": {
|
||||
"name": "jaegerBackendMigration",
|
||||
"resourceVersion": "1731599633815",
|
||||
"creationTimestamp": "2024-11-14T15:53:53Z"
|
||||
"creationTimestamp": "2024-11-15T14:40:20Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables querying the Jaeger data source without the proxy",
|
||||
@@ -2026,7 +2027,7 @@
|
||||
"metadata": {
|
||||
"name": "k8SFolderCounts",
|
||||
"resourceVersion": "1735294794086",
|
||||
"creationTimestamp": "2024-12-27T10:19:54Z"
|
||||
"creationTimestamp": "2024-12-27T17:10:44Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable folder's api server counts",
|
||||
@@ -2039,7 +2040,7 @@
|
||||
"metadata": {
|
||||
"name": "k8SFolderMove",
|
||||
"resourceVersion": "1735294794086",
|
||||
"creationTimestamp": "2024-12-27T10:19:54Z"
|
||||
"creationTimestamp": "2024-12-27T17:10:44Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable folder's api server move",
|
||||
@@ -2081,7 +2082,7 @@
|
||||
"metadata": {
|
||||
"name": "kubernetesCliDashboards",
|
||||
"resourceVersion": "1733520389522",
|
||||
"creationTimestamp": "2024-12-06T21:26:29Z"
|
||||
"creationTimestamp": "2024-12-13T22:55:43Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Use the k8s client to retrieve dashboards internally",
|
||||
@@ -2121,6 +2122,7 @@
|
||||
"name": "kubernetesFolders",
|
||||
"resourceVersion": "1725863636605",
|
||||
"creationTimestamp": "2024-09-10T09:22:08Z",
|
||||
"deletionTimestamp": "2025-01-22T20:49:15Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-09-09 06:33:56.605329 +0000 UTC"
|
||||
}
|
||||
@@ -2135,7 +2137,7 @@
|
||||
"metadata": {
|
||||
"name": "kubernetesFoldersServiceV2",
|
||||
"resourceVersion": "1735336477446",
|
||||
"creationTimestamp": "2024-12-27T21:54:37Z"
|
||||
"creationTimestamp": "2025-01-13T21:15:35Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Use the Folders Service V2, and route Folder Service requests to k8s",
|
||||
@@ -2165,7 +2167,7 @@
|
||||
"metadata": {
|
||||
"name": "kubernetesRestore",
|
||||
"resourceVersion": "1735880498698",
|
||||
"creationTimestamp": "2025-01-03T05:01:38Z"
|
||||
"creationTimestamp": "2025-01-03T14:48:47Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Allow restoring objects in k8s",
|
||||
@@ -2219,7 +2221,7 @@
|
||||
"metadata": {
|
||||
"name": "logQLScope",
|
||||
"resourceVersion": "1730842404843",
|
||||
"creationTimestamp": "2024-11-05T21:33:24Z"
|
||||
"creationTimestamp": "2024-11-11T11:53:24Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "In-development feature that will allow injection of labels into loki queries.",
|
||||
@@ -2338,7 +2340,7 @@
|
||||
"metadata": {
|
||||
"name": "lokiLabelNamesQueryApi",
|
||||
"resourceVersion": "1734096677730",
|
||||
"creationTimestamp": "2024-12-13T13:31:17Z"
|
||||
"creationTimestamp": "2024-12-13T14:31:41Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Defaults to using the Loki `/labels` API instead of `/series`",
|
||||
@@ -2364,7 +2366,7 @@
|
||||
"name": "lokiMetricDataplane",
|
||||
"resourceVersion": "1720021873452",
|
||||
"creationTimestamp": "2023-04-13T13:07:08Z",
|
||||
"deletionTimestamp": "2024-08-21T13:49:48Z",
|
||||
"deletionTimestamp": "2024-11-26T16:32:17Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-07-03 15:51:13.452477 +0000 UTC"
|
||||
}
|
||||
@@ -2469,7 +2471,7 @@
|
||||
"metadata": {
|
||||
"name": "lokiShardSplitting",
|
||||
"resourceVersion": "1729678036788",
|
||||
"creationTimestamp": "2024-10-23T10:06:42Z",
|
||||
"creationTimestamp": "2024-10-23T11:21:03Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-10-23 10:07:16.788828 +0000 UTC"
|
||||
}
|
||||
@@ -2660,7 +2662,7 @@
|
||||
"name": "notificationBanner",
|
||||
"resourceVersion": "1727777007488",
|
||||
"creationTimestamp": "2024-05-13T09:32:34Z",
|
||||
"deletionTimestamp": "2025-01-10T06:02:47Z",
|
||||
"deletionTimestamp": "2025-01-10T10:18:43Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-10-01 10:03:27.48823 +0000 UTC"
|
||||
}
|
||||
@@ -2706,8 +2708,8 @@
|
||||
"metadata": {
|
||||
"name": "onPremToCloudMigrationsAlerts",
|
||||
"resourceVersion": "1728048163201",
|
||||
"creationTimestamp": "2024-10-04T13:22:43Z",
|
||||
"deletionTimestamp": "2024-12-02T13:37:41Z"
|
||||
"creationTimestamp": "2024-10-07T10:53:24Z",
|
||||
"deletionTimestamp": "2024-12-17T11:56:18Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the migration of alerts and its child resources to your Grafana Cloud stack. Requires `onPremToCloudMigrations` to be enabled in conjunction.",
|
||||
@@ -2719,7 +2721,7 @@
|
||||
"metadata": {
|
||||
"name": "onPremToCloudMigrationsAuthApiMig",
|
||||
"resourceVersion": "1732033809064",
|
||||
"creationTimestamp": "2024-11-19T16:30:09Z"
|
||||
"creationTimestamp": "2024-11-21T18:46:06Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the use of auth api instead of gcom for internal token services. Requires `onPremToCloudMigrations` to be enabled in conjunction.",
|
||||
@@ -2795,7 +2797,7 @@
|
||||
"name": "panelTitleSearchInV1",
|
||||
"resourceVersion": "1718727528075",
|
||||
"creationTimestamp": "2023-10-13T12:04:24Z",
|
||||
"deletionTimestamp": "2025-01-20T20:07:05Z"
|
||||
"deletionTimestamp": "2025-01-21T09:59:32Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable searching for dashboards using panel title in search v1",
|
||||
@@ -2809,7 +2811,7 @@
|
||||
"name": "passScopeToDashboardApi",
|
||||
"resourceVersion": "1718290335877",
|
||||
"creationTimestamp": "2024-06-20T15:49:19Z",
|
||||
"deletionTimestamp": "2024-10-14T10:53:41Z"
|
||||
"deletionTimestamp": "2024-10-25T12:56:54Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the passing of scopes to dashboards fetching in Grafana",
|
||||
@@ -2823,7 +2825,7 @@
|
||||
"metadata": {
|
||||
"name": "passwordlessMagicLinkAuthentication",
|
||||
"resourceVersion": "1730232874003",
|
||||
"creationTimestamp": "2024-10-29T20:14:34Z"
|
||||
"creationTimestamp": "2024-11-14T13:50:55Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable passwordless login via magic link authentication",
|
||||
@@ -2877,7 +2879,7 @@
|
||||
"metadata": {
|
||||
"name": "playlistsReconciler",
|
||||
"resourceVersion": "1734463170112",
|
||||
"creationTimestamp": "2024-11-01T12:08:30Z",
|
||||
"creationTimestamp": "2024-12-20T03:09:31Z",
|
||||
"deletionTimestamp": "2024-12-19T19:17:00Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-12-17 19:19:30.112629 +0000 UTC"
|
||||
@@ -2966,7 +2968,7 @@
|
||||
"metadata": {
|
||||
"name": "pluginsSriChecks",
|
||||
"resourceVersion": "1727785264632",
|
||||
"creationTimestamp": "2024-10-01T12:21:04Z"
|
||||
"creationTimestamp": "2024-10-04T12:55:09Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables SRI checks for plugin assets",
|
||||
@@ -2978,7 +2980,7 @@
|
||||
"metadata": {
|
||||
"name": "preinstallAutoUpdate",
|
||||
"resourceVersion": "1731581146864",
|
||||
"creationTimestamp": "2024-11-06T14:45:43Z",
|
||||
"creationTimestamp": "2024-11-07T12:14:25Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-11-14 10:45:46.864585 +0000 UTC"
|
||||
}
|
||||
@@ -3058,7 +3060,7 @@
|
||||
"name": "prometheusConfigOverhaulAuth",
|
||||
"resourceVersion": "1720021873452",
|
||||
"creationTimestamp": "2023-07-26T16:09:53Z",
|
||||
"deletionTimestamp": "2025-01-02T20:43:41Z",
|
||||
"deletionTimestamp": "2025-01-02T21:19:11Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-07-03 15:51:13.452477 +0000 UTC"
|
||||
}
|
||||
@@ -3107,7 +3109,7 @@
|
||||
"name": "prometheusMetricEncyclopedia",
|
||||
"resourceVersion": "1720021873452",
|
||||
"creationTimestamp": "2023-03-07T18:41:05Z",
|
||||
"deletionTimestamp": "2024-12-30T14:42:45Z",
|
||||
"deletionTimestamp": "2024-12-30T21:16:04Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-07-03 15:51:13.452477 +0000 UTC"
|
||||
}
|
||||
@@ -3156,7 +3158,7 @@
|
||||
"metadata": {
|
||||
"name": "prometheusSpecialCharsInLabelValues",
|
||||
"resourceVersion": "1735845919509",
|
||||
"creationTimestamp": "2024-12-12T23:52:48Z",
|
||||
"creationTimestamp": "2024-12-18T21:31:08Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-01-02 19:25:19.509884 +0000 UTC"
|
||||
}
|
||||
@@ -3172,7 +3174,7 @@
|
||||
"metadata": {
|
||||
"name": "prometheusUsesCombobox",
|
||||
"resourceVersion": "1735845919509",
|
||||
"creationTimestamp": "2024-09-12T11:19:18Z",
|
||||
"creationTimestamp": "2024-10-23T11:18:33Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-01-02 19:25:19.509884 +0000 UTC"
|
||||
}
|
||||
@@ -3187,7 +3189,7 @@
|
||||
"metadata": {
|
||||
"name": "provisioning",
|
||||
"resourceVersion": "1732265054297",
|
||||
"creationTimestamp": "2024-11-22T08:44:14Z"
|
||||
"creationTimestamp": "2024-11-22T09:03:50Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Next generation provisioning... and git",
|
||||
@@ -3201,7 +3203,7 @@
|
||||
"name": "publicDashboards",
|
||||
"resourceVersion": "1720021873452",
|
||||
"creationTimestamp": "2022-04-07T18:30:19Z",
|
||||
"deletionTimestamp": "2024-11-15T16:38:53Z",
|
||||
"deletionTimestamp": "2024-11-20T14:36:19Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-07-03 15:51:13.452477 +0000 UTC"
|
||||
}
|
||||
@@ -3265,7 +3267,7 @@
|
||||
"metadata": {
|
||||
"name": "queryLibraryDashboards",
|
||||
"resourceVersion": "1736850377404",
|
||||
"creationTimestamp": "2025-01-14T10:24:54Z",
|
||||
"creationTimestamp": "2025-01-14T11:01:15Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-01-14 10:26:17.404592 +0000 UTC"
|
||||
}
|
||||
@@ -3394,7 +3396,7 @@
|
||||
"metadata": {
|
||||
"name": "reloadDashboardsOnParamsChange",
|
||||
"resourceVersion": "1728903221522",
|
||||
"creationTimestamp": "2024-10-14T10:53:41Z"
|
||||
"creationTimestamp": "2024-10-25T12:56:54Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables reload of dashboards on scopes, time range and variables changes",
|
||||
@@ -3434,7 +3436,7 @@
|
||||
"metadata": {
|
||||
"name": "reportingUseRawTimeRange",
|
||||
"resourceVersion": "1735810729877",
|
||||
"creationTimestamp": "2024-11-13T15:48:28Z",
|
||||
"creationTimestamp": "2024-11-14T20:08:03Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-01-02 09:38:49.877519888 +0000 UTC"
|
||||
}
|
||||
@@ -3450,7 +3452,7 @@
|
||||
"metadata": {
|
||||
"name": "rolePickerDrawer",
|
||||
"resourceVersion": "1727337187819",
|
||||
"creationTimestamp": "2024-09-26T07:53:07Z"
|
||||
"creationTimestamp": "2024-09-26T12:51:38Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the new role picker drawer design",
|
||||
@@ -3476,7 +3478,7 @@
|
||||
"metadata": {
|
||||
"name": "scopeApi",
|
||||
"resourceVersion": "1732690644377",
|
||||
"creationTimestamp": "2024-11-27T06:57:24Z"
|
||||
"creationTimestamp": "2024-11-27T07:58:25Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "In-development feature flag for the scope api using the app platform.",
|
||||
@@ -3517,7 +3519,7 @@
|
||||
"name": "singleTopNav",
|
||||
"resourceVersion": "1732104041490",
|
||||
"creationTimestamp": "2024-08-29T08:48:32Z",
|
||||
"deletionTimestamp": "2024-12-13T11:25:25Z",
|
||||
"deletionTimestamp": "2024-12-17T13:32:38Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-11-20 12:00:41.490792 +0000 UTC"
|
||||
}
|
||||
@@ -3560,7 +3562,7 @@
|
||||
"metadata": {
|
||||
"name": "sqlQuerybuilderFunctionParameters",
|
||||
"resourceVersion": "1718487716739",
|
||||
"creationTimestamp": "2024-06-15T21:41:56Z"
|
||||
"creationTimestamp": "2024-11-04T16:13:35Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables SQL query builder function parameters",
|
||||
@@ -3676,7 +3678,7 @@
|
||||
"metadata": {
|
||||
"name": "teamHttpHeadersMimir",
|
||||
"resourceVersion": "1736763800062",
|
||||
"creationTimestamp": "2025-01-13T10:23:20Z"
|
||||
"creationTimestamp": "2025-01-13T10:42:47Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables LBAC for datasources for Mimir to apply LBAC filtering of metrics to the client requests for users in teams",
|
||||
@@ -3688,7 +3690,7 @@
|
||||
"metadata": {
|
||||
"name": "timeRangeProvider",
|
||||
"resourceVersion": "1728565214224",
|
||||
"creationTimestamp": "2024-10-10T13:00:14Z"
|
||||
"creationTimestamp": "2024-10-22T10:52:33Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables time pickers sync",
|
||||
@@ -3717,7 +3719,7 @@
|
||||
"name": "topnav",
|
||||
"resourceVersion": "1720021873452",
|
||||
"creationTimestamp": "2022-06-20T14:25:43Z",
|
||||
"deletionTimestamp": "2024-10-15T15:00:51Z",
|
||||
"deletionTimestamp": "2024-10-17T09:18:30Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-07-03 15:51:13.452477 +0000 UTC"
|
||||
}
|
||||
@@ -3785,7 +3787,7 @@
|
||||
"metadata": {
|
||||
"name": "unifiedHistory",
|
||||
"resourceVersion": "1734085219453",
|
||||
"creationTimestamp": "2024-12-13T10:20:19Z"
|
||||
"creationTimestamp": "2024-12-13T10:41:18Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Displays the navigation history so the user can navigate back to previous pages",
|
||||
@@ -3845,7 +3847,7 @@
|
||||
"metadata": {
|
||||
"name": "unifiedStorageBigObjectsSupport",
|
||||
"resourceVersion": "1728994158474",
|
||||
"creationTimestamp": "2024-10-15T12:09:18Z"
|
||||
"creationTimestamp": "2024-10-17T10:18:29Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables to save big objects in blob storage",
|
||||
@@ -3857,7 +3859,7 @@
|
||||
"metadata": {
|
||||
"name": "unifiedStorageSearch",
|
||||
"resourceVersion": "1726771421439",
|
||||
"creationTimestamp": "2024-09-19T18:43:41Z"
|
||||
"creationTimestamp": "2024-09-30T19:46:14Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable unified storage search",
|
||||
@@ -3871,7 +3873,7 @@
|
||||
"metadata": {
|
||||
"name": "unifiedStorageSearch",
|
||||
"resourceVersion": "1726771421439",
|
||||
"creationTimestamp": "2024-09-19T18:43:41Z",
|
||||
"creationTimestamp": "2024-09-30T19:46:14Z",
|
||||
"deletionTimestamp": "2024-10-11T14:56:04Z"
|
||||
},
|
||||
"spec": {
|
||||
@@ -3886,7 +3888,7 @@
|
||||
"metadata": {
|
||||
"name": "unifiedStorageSearchPermissionFiltering",
|
||||
"resourceVersion": "1737489629408",
|
||||
"creationTimestamp": "2025-01-21T20:00:29Z"
|
||||
"creationTimestamp": "2025-01-22T11:38:37Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable permission filtering on unified storage search",
|
||||
@@ -3900,7 +3902,7 @@
|
||||
"metadata": {
|
||||
"name": "unifiedStorageSearchSprinkles",
|
||||
"resourceVersion": "1734563607668",
|
||||
"creationTimestamp": "2024-12-18T23:13:27Z"
|
||||
"creationTimestamp": "2024-12-18T17:00:54Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable sprinkles on unified storage search",
|
||||
@@ -3914,7 +3916,7 @@
|
||||
"metadata": {
|
||||
"name": "unifiedStorageSearchUI",
|
||||
"resourceVersion": "1734563607668",
|
||||
"creationTimestamp": "2024-12-10T21:28:55Z",
|
||||
"creationTimestamp": "2024-12-19T18:21:48Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-12-18 23:13:27.66802 +0000 UTC"
|
||||
}
|
||||
@@ -3960,7 +3962,7 @@
|
||||
"metadata": {
|
||||
"name": "useV2DashboardsAPI",
|
||||
"resourceVersion": "1732535420861",
|
||||
"creationTimestamp": "2024-11-25T11:50:20Z"
|
||||
"creationTimestamp": "2024-12-17T21:17:09Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Use the v2 kubernetes API in the frontend for dashboards",
|
||||
@@ -3973,7 +3975,7 @@
|
||||
"metadata": {
|
||||
"name": "userStorageAPI",
|
||||
"resourceVersion": "1736438999910",
|
||||
"creationTimestamp": "2024-10-29T12:18:41Z",
|
||||
"creationTimestamp": "2024-11-12T11:56:41Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-01-09 16:09:59.910083 +0000 UTC"
|
||||
}
|
||||
@@ -4004,7 +4006,7 @@
|
||||
"name": "vizAndWidgetSplit",
|
||||
"resourceVersion": "1718727528075",
|
||||
"creationTimestamp": "2023-06-27T10:22:13Z",
|
||||
"deletionTimestamp": "2024-10-30T14:21:33Z"
|
||||
"deletionTimestamp": "2024-10-30T16:12:03Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Split panels between visualizations and widgets",
|
||||
@@ -4043,7 +4045,7 @@
|
||||
"metadata": {
|
||||
"name": "zipkinBackendMigration",
|
||||
"resourceVersion": "1733846643829",
|
||||
"creationTimestamp": "2024-11-06T15:39:38Z",
|
||||
"creationTimestamp": "2024-11-07T09:35:53Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-12-10 16:04:03.82919 +0000 UTC"
|
||||
}
|
||||
|
||||
@@ -11,12 +11,13 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/dskit/concurrency"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"github.com/grafana/dskit/concurrency"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
@@ -32,6 +33,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
"github.com/grafana/grafana/pkg/services/publicdashboards"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity"
|
||||
@@ -45,15 +47,16 @@ import (
|
||||
const FULLPATH_SEPARATOR = "/"
|
||||
|
||||
type Service struct {
|
||||
store folder.Store
|
||||
unifiedStore folder.Store
|
||||
db db.DB
|
||||
log *slog.Logger
|
||||
dashboardStore dashboards.Store
|
||||
dashboardFolderStore folder.FolderStore
|
||||
features featuremgmt.FeatureToggles
|
||||
accessControl accesscontrol.AccessControl
|
||||
k8sclient folderK8sHandler
|
||||
store folder.Store
|
||||
unifiedStore folder.Store
|
||||
db db.DB
|
||||
log *slog.Logger
|
||||
dashboardStore dashboards.Store
|
||||
dashboardFolderStore folder.FolderStore
|
||||
features featuremgmt.FeatureToggles
|
||||
accessControl accesscontrol.AccessControl
|
||||
k8sclient folderK8sHandler
|
||||
publicDashboardService publicdashboards.ServiceWrapper
|
||||
// bus is currently used to publish event in case of folder full path change.
|
||||
// For example when a folder is moved to another folder or when a folder is renamed.
|
||||
bus bus.Bus
|
||||
@@ -73,22 +76,24 @@ func ProvideService(
|
||||
db db.DB, // DB for the (new) nested folder store
|
||||
features featuremgmt.FeatureToggles,
|
||||
supportBundles supportbundles.Service,
|
||||
publicDashboardService publicdashboards.ServiceWrapper,
|
||||
cfg *setting.Cfg,
|
||||
r prometheus.Registerer,
|
||||
tracer tracing.Tracer,
|
||||
) *Service {
|
||||
srv := &Service{
|
||||
log: slog.Default().With("logger", "folder-service"),
|
||||
dashboardStore: dashboardStore,
|
||||
dashboardFolderStore: folderStore,
|
||||
store: store,
|
||||
features: features,
|
||||
accessControl: ac,
|
||||
bus: bus,
|
||||
db: db,
|
||||
registry: make(map[string]folder.RegistryService),
|
||||
metrics: newFoldersMetrics(r),
|
||||
tracer: tracer,
|
||||
log: slog.Default().With("logger", "folder-service"),
|
||||
dashboardStore: dashboardStore,
|
||||
dashboardFolderStore: folderStore,
|
||||
store: store,
|
||||
features: features,
|
||||
accessControl: ac,
|
||||
bus: bus,
|
||||
db: db,
|
||||
registry: make(map[string]folder.RegistryService),
|
||||
metrics: newFoldersMetrics(r),
|
||||
tracer: tracer,
|
||||
publicDashboardService: publicDashboardService,
|
||||
}
|
||||
srv.DBMigration(db)
|
||||
|
||||
@@ -315,10 +320,6 @@ func (s *Service) GetLegacy(ctx context.Context, q *folder.GetFolderQuery) (*fol
|
||||
f.FullpathUIDs = f.UID // set full path to the folder UID
|
||||
}
|
||||
|
||||
if s.features.IsEnabled(ctx, featuremgmt.FlagKubernetesFolders) {
|
||||
f, err = s.setFullpath(ctx, f, q.SignedInUser, true)
|
||||
}
|
||||
|
||||
return f, err
|
||||
}
|
||||
|
||||
@@ -395,7 +396,7 @@ func (s *Service) GetChildrenLegacy(ctx context.Context, q *folder.GetChildrenQu
|
||||
|
||||
// we only need to check access to the folder
|
||||
// if the parent is accessible then the subfolders are accessible as well (due to inheritance)
|
||||
g, err := guardian.NewByUID(ctx, q.UID, q.OrgID, q.SignedInUser)
|
||||
g, err := guardian.NewByFolderUID(ctx, q.UID, q.OrgID, q.SignedInUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -784,13 +785,6 @@ func (s *Service) CreateLegacy(ctx context.Context, cmd *folder.CreateFolderComm
|
||||
f.ParentUID = nestedFolder.ParentUID
|
||||
}
|
||||
|
||||
if s.features.IsEnabled(ctx, featuremgmt.FlagKubernetesFolders) {
|
||||
f, err = s.setFullpath(ctx, f, user, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
@@ -951,7 +945,7 @@ func (s *Service) DeleteLegacy(ctx context.Context, cmd *folder.DeleteFolderComm
|
||||
return folder.ErrBadRequest.Errorf("invalid orgID")
|
||||
}
|
||||
|
||||
guard, err := guardian.NewByUID(ctx, cmd.UID, cmd.OrgID, cmd.SignedInUser)
|
||||
guard, err := guardian.NewByFolderUID(ctx, cmd.UID, cmd.OrgID, cmd.SignedInUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -992,6 +986,12 @@ func (s *Service) DeleteLegacy(ctx context.Context, cmd *folder.DeleteFolderComm
|
||||
}
|
||||
}
|
||||
|
||||
err = s.store.Delete(ctx, []string{cmd.UID}, cmd.OrgID)
|
||||
if err != nil {
|
||||
s.log.InfoContext(ctx, "failed deleting folder", "org_id", cmd.OrgID, "uid", cmd.UID, "err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err = s.legacyDelete(ctx, cmd, folders); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1012,7 +1012,28 @@ func (s *Service) deleteChildrenInFolder(ctx context.Context, orgID int64, folde
|
||||
}
|
||||
|
||||
func (s *Service) legacyDelete(ctx context.Context, cmd *folder.DeleteFolderCommand, folderUIDs []string) error {
|
||||
// if dashboard restore is on we don't delete public dashboards, the hard delete will take care of it later
|
||||
if !s.features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) {
|
||||
// We need a list of dashboard uids inside the folder to delete related public dashboards
|
||||
dashes, err := s.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{SignedInUser: cmd.SignedInUser, FolderUIDs: folderUIDs, OrgId: cmd.OrgID})
|
||||
if err != nil {
|
||||
return folder.ErrInternal.Errorf("failed to fetch dashboards: %w", err)
|
||||
}
|
||||
|
||||
dashboardUIDs := make([]string, 0, len(dashes))
|
||||
for _, dashboard := range dashes {
|
||||
dashboardUIDs = append(dashboardUIDs, dashboard.UID)
|
||||
}
|
||||
|
||||
// Delete all public dashboards in the folders
|
||||
err = s.publicDashboardService.DeleteByDashboardUIDs(ctx, cmd.OrgID, dashboardUIDs)
|
||||
if err != nil {
|
||||
return folder.ErrInternal.Errorf("failed to delete public dashboards: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO use bulk delete
|
||||
// Delete all dashboards in the folders
|
||||
for _, folderUID := range folderUIDs {
|
||||
// nolint:staticcheck
|
||||
deleteCmd := dashboards.DeleteDashboardCommand{OrgID: cmd.OrgID, UID: folderUID, ForceDeleteFolderRules: cmd.ForceDeleteRules}
|
||||
@@ -1020,6 +1041,7 @@ func (s *Service) legacyDelete(ctx context.Context, cmd *folder.DeleteFolderComm
|
||||
return toFolderError(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1240,11 +1262,11 @@ func (s *Service) nestedFolderDelete(ctx context.Context, cmd *folder.DeleteFold
|
||||
for _, f := range descendants {
|
||||
descendantUIDs = append(descendantUIDs, f.UID)
|
||||
}
|
||||
s.log.InfoContext(ctx, "deleting folder and its descendants", "org_id", cmd.OrgID, "uid", cmd.UID)
|
||||
toDelete := append(descendantUIDs, cmd.UID)
|
||||
err = s.store.Delete(ctx, toDelete, cmd.OrgID)
|
||||
s.log.InfoContext(ctx, "deleting folder descendants", "org_id", cmd.OrgID, "uid", cmd.UID)
|
||||
|
||||
err = s.store.Delete(ctx, descendantUIDs, cmd.OrgID)
|
||||
if err != nil {
|
||||
s.log.InfoContext(ctx, "failed deleting folder", "org_id", cmd.OrgID, "uid", cmd.UID, "err", err)
|
||||
s.log.InfoContext(ctx, "failed deleting descendants", "org_id", cmd.OrgID, "parent_uid", cmd.UID, "err", err)
|
||||
return descendantUIDs, err
|
||||
}
|
||||
return descendantUIDs, nil
|
||||
@@ -1418,13 +1440,19 @@ func getGuardianForSavePermissionCheck(ctx context.Context, d *dashboards.Dashbo
|
||||
// if it's a new dashboard/folder check the parent folder permissions
|
||||
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc()
|
||||
// nolint:staticcheck
|
||||
guard, err := guardian.New(ctx, d.FolderID, d.OrgID, user)
|
||||
guard, err := guardian.NewByFolder(ctx, &folder.Folder{
|
||||
ID: d.FolderID, // nolint:staticcheck
|
||||
OrgID: d.OrgID,
|
||||
}, d.OrgID, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return guard, nil
|
||||
}
|
||||
guard, err := guardian.NewByDashboard(ctx, d, d.OrgID, user)
|
||||
guard, err := guardian.NewByFolder(ctx, &folder.Folder{
|
||||
UID: d.UID,
|
||||
OrgID: d.OrgID,
|
||||
}, d.OrgID, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
ngstore "github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/publicdashboards"
|
||||
"github.com/grafana/grafana/pkg/services/quota/quotatest"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity"
|
||||
@@ -64,7 +65,7 @@ func TestIntegrationProvideFolderService(t *testing.T) {
|
||||
db, cfg := db.InitTestDBWithCfg(t)
|
||||
store := ProvideStore(db)
|
||||
ProvideService(store, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), nil, nil, db,
|
||||
featuremgmt.WithFeatures(), supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest())
|
||||
featuremgmt.WithFeatures(), supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest())
|
||||
|
||||
require.Len(t, ac.Calls.RegisterAttributeScopeResolver, 2)
|
||||
})
|
||||
@@ -80,7 +81,7 @@ func TestIntegrationFolderService(t *testing.T) {
|
||||
nestedFolderStore := ProvideStore(db)
|
||||
|
||||
folderStore := foldertest.NewFakeFolderStore(t)
|
||||
|
||||
publicDashboardService := publicdashboards.NewFakePublicDashboardServiceWrapper(t)
|
||||
features := featuremgmt.WithFeatures()
|
||||
|
||||
alertingStore := ngstore.DBstore{
|
||||
@@ -91,17 +92,18 @@ func TestIntegrationFolderService(t *testing.T) {
|
||||
}
|
||||
|
||||
service := &Service{
|
||||
log: slog.New(logtest.NewTestHandler(t)).With("logger", "test-folder-service"),
|
||||
dashboardStore: dashStore,
|
||||
dashboardFolderStore: folderStore,
|
||||
store: nestedFolderStore,
|
||||
features: features,
|
||||
bus: bus.ProvideBus(tracing.InitializeTracerForTest()),
|
||||
db: db,
|
||||
accessControl: acimpl.ProvideAccessControl(features),
|
||||
metrics: newFoldersMetrics(nil),
|
||||
registry: make(map[string]folder.RegistryService),
|
||||
tracer: tracing.InitializeTracerForTest(),
|
||||
log: slog.New(logtest.NewTestHandler(t)).With("logger", "test-folder-service"),
|
||||
dashboardStore: dashStore,
|
||||
dashboardFolderStore: folderStore,
|
||||
store: nestedFolderStore,
|
||||
publicDashboardService: publicDashboardService,
|
||||
features: features,
|
||||
bus: bus.ProvideBus(tracing.InitializeTracerForTest()),
|
||||
db: db,
|
||||
accessControl: acimpl.ProvideAccessControl(features),
|
||||
metrics: newFoldersMetrics(nil),
|
||||
registry: make(map[string]folder.RegistryService),
|
||||
tracer: tracing.InitializeTracerForTest(),
|
||||
}
|
||||
|
||||
require.NoError(t, service.RegisterService(alertingStore))
|
||||
@@ -263,11 +265,13 @@ func TestIntegrationFolderService(t *testing.T) {
|
||||
f := folder.NewFolder(util.GenerateShortUID(), "")
|
||||
f.UID = util.GenerateShortUID()
|
||||
folderStore.On("GetFolderByUID", mock.Anything, orgID, f.UID).Return(f, nil)
|
||||
publicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
|
||||
|
||||
var actualCmd *dashboards.DeleteDashboardCommand
|
||||
dashStore.On("DeleteDashboard", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
|
||||
actualCmd = args.Get(1).(*dashboards.DeleteDashboardCommand)
|
||||
}).Return(nil).Once()
|
||||
dashStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{}, nil).Once()
|
||||
|
||||
expectedForceDeleteRules := rand.Int63()%2 == 0
|
||||
err := service.Delete(context.Background(), &folder.DeleteFolderCommand{
|
||||
@@ -427,22 +431,24 @@ func TestIntegrationNestedFolderService(t *testing.T) {
|
||||
dashStore, err := database.ProvideDashboardStore(db, cfg, featuresFlagOn, tagimpl.ProvideService(db))
|
||||
require.NoError(t, err)
|
||||
nestedFolderStore := ProvideStore(db)
|
||||
publicDashboardFakeService := publicdashboards.NewFakePublicDashboardServiceWrapper(t)
|
||||
|
||||
b := bus.ProvideBus(tracing.InitializeTracerForTest())
|
||||
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
|
||||
|
||||
serviceWithFlagOn := &Service{
|
||||
log: slog.New(logtest.NewTestHandler(t)).With("logger", "test-folder-service"),
|
||||
dashboardStore: dashStore,
|
||||
dashboardFolderStore: folderStore,
|
||||
store: nestedFolderStore,
|
||||
features: featuresFlagOn,
|
||||
bus: b,
|
||||
db: db,
|
||||
accessControl: ac,
|
||||
registry: make(map[string]folder.RegistryService),
|
||||
metrics: newFoldersMetrics(nil),
|
||||
tracer: tracing.InitializeTracerForTest(),
|
||||
log: slog.New(logtest.NewTestHandler(t)).With("logger", "test-folder-service"),
|
||||
dashboardStore: dashStore,
|
||||
dashboardFolderStore: folderStore,
|
||||
store: nestedFolderStore,
|
||||
features: featuresFlagOn,
|
||||
bus: b,
|
||||
db: db,
|
||||
accessControl: ac,
|
||||
registry: make(map[string]folder.RegistryService),
|
||||
metrics: newFoldersMetrics(nil),
|
||||
tracer: tracing.InitializeTracerForTest(),
|
||||
publicDashboardService: publicDashboardFakeService,
|
||||
}
|
||||
|
||||
signedInUser := user.SignedInUser{UserID: 1, OrgID: orgID, Permissions: map[int64]map[string][]string{
|
||||
@@ -485,8 +491,9 @@ func TestIntegrationNestedFolderService(t *testing.T) {
|
||||
// CanEditValue is required to create library elements
|
||||
CanEditValue: true,
|
||||
})
|
||||
publicDashboardFakeService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuresFlagOn, folderPermissions, ac, serviceWithFlagOn, nestedFolderStore, nil, nil, nil, nil, quotaService, nil)
|
||||
dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuresFlagOn, folderPermissions, ac, serviceWithFlagOn, nestedFolderStore, nil, nil, nil, nil, quotaService, nil, publicDashboardFakeService)
|
||||
require.NoError(t, err)
|
||||
dashSrv.RegisterDashboardPermissions(dashboardPermissions)
|
||||
|
||||
@@ -548,16 +555,17 @@ func TestIntegrationNestedFolderService(t *testing.T) {
|
||||
nestedFolderStore := ProvideStore(db)
|
||||
|
||||
serviceWithFlagOff := &Service{
|
||||
log: slog.New(logtest.NewTestHandler(t)).With("logger", "test-folder-service"),
|
||||
dashboardStore: dashStore,
|
||||
dashboardFolderStore: folderStore,
|
||||
store: nestedFolderStore,
|
||||
features: featuresFlagOff,
|
||||
bus: b,
|
||||
db: db,
|
||||
registry: make(map[string]folder.RegistryService),
|
||||
metrics: newFoldersMetrics(nil),
|
||||
tracer: tracing.InitializeTracerForTest(),
|
||||
log: slog.New(logtest.NewTestHandler(t)).With("logger", "test-folder-service"),
|
||||
dashboardStore: dashStore,
|
||||
dashboardFolderStore: folderStore,
|
||||
store: nestedFolderStore,
|
||||
features: featuresFlagOff,
|
||||
bus: b,
|
||||
db: db,
|
||||
registry: make(map[string]folder.RegistryService),
|
||||
metrics: newFoldersMetrics(nil),
|
||||
tracer: tracing.InitializeTracerForTest(),
|
||||
publicDashboardService: publicDashboardFakeService,
|
||||
}
|
||||
|
||||
origNewGuardian := guardian.New
|
||||
@@ -568,8 +576,10 @@ func TestIntegrationNestedFolderService(t *testing.T) {
|
||||
CanEditValue: true,
|
||||
})
|
||||
|
||||
publicDashboardFakeService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuresFlagOff,
|
||||
folderPermissions, ac, serviceWithFlagOff, nestedFolderStore, nil, nil, nil, nil, quotaService, nil)
|
||||
folderPermissions, ac, serviceWithFlagOff, nestedFolderStore, nil, nil, nil, nil, quotaService, nil, publicDashboardFakeService)
|
||||
require.NoError(t, err)
|
||||
dashSrv.RegisterDashboardPermissions(dashboardPermissions)
|
||||
alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOff, db, serviceWithFlagOff, dashSrv, ac, b)
|
||||
@@ -628,14 +638,15 @@ func TestIntegrationNestedFolderService(t *testing.T) {
|
||||
t.Run("Should delete folders", func(t *testing.T) {
|
||||
featuresFlagOff := featuremgmt.WithFeatures()
|
||||
serviceWithFlagOff := &Service{
|
||||
log: slog.New(logtest.NewTestHandler(t)).With("logger", "test-folder-service"),
|
||||
dashboardFolderStore: folderStore,
|
||||
features: featuresFlagOff,
|
||||
bus: b,
|
||||
db: db,
|
||||
registry: make(map[string]folder.RegistryService),
|
||||
metrics: newFoldersMetrics(nil),
|
||||
tracer: tracing.InitializeTracerForTest(),
|
||||
log: slog.New(logtest.NewTestHandler(t)).With("logger", "test-folder-service"),
|
||||
dashboardFolderStore: folderStore,
|
||||
features: featuresFlagOff,
|
||||
bus: b,
|
||||
db: db,
|
||||
registry: make(map[string]folder.RegistryService),
|
||||
metrics: newFoldersMetrics(nil),
|
||||
tracer: tracing.InitializeTracerForTest(),
|
||||
publicDashboardService: publicDashboardFakeService,
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
@@ -713,8 +724,9 @@ func TestIntegrationNestedFolderService(t *testing.T) {
|
||||
nestedFolderStore := ProvideStore(db)
|
||||
tc.service.dashboardStore = dashStore
|
||||
tc.service.store = nestedFolderStore
|
||||
publicDashboardFakeService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, tc.featuresFlag, folderPermissions, ac, tc.service, tc.service.store, nil, nil, nil, nil, quotaService, nil)
|
||||
dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, tc.featuresFlag, folderPermissions, ac, tc.service, tc.service.store, nil, nil, nil, nil, quotaService, nil, publicDashboardFakeService)
|
||||
require.NoError(t, err)
|
||||
dashSrv.RegisterDashboardPermissions(dashboardPermissions)
|
||||
|
||||
@@ -1502,6 +1514,7 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) {
|
||||
nil,
|
||||
quotaService,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
dashboardService.RegisterDashboardPermissions(dashboardPermissions)
|
||||
|
||||
@@ -586,6 +586,26 @@ func (s *Service) deleteFromApiServer(ctx context.Context, cmd *folder.DeleteFol
|
||||
if alertRulesInFolder > 0 {
|
||||
return folder.ErrFolderNotEmpty.Errorf("folder contains %d alert rules", alertRulesInFolder)
|
||||
}
|
||||
|
||||
// if dashboard restore is on we don't delete public dashboards, the hard delete will take care of it later
|
||||
if !s.features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) {
|
||||
// We need a list of dashboard uids inside the folder to delete related public dashboards
|
||||
dashes, err := s.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{SignedInUser: cmd.SignedInUser, FolderUIDs: folders, OrgId: cmd.OrgID})
|
||||
if err != nil {
|
||||
return folder.ErrInternal.Errorf("failed to fetch dashboards: %w", err)
|
||||
}
|
||||
|
||||
dashboardUIDs := make([]string, 0, len(dashes))
|
||||
for _, dashboard := range dashes {
|
||||
dashboardUIDs = append(dashboardUIDs, dashboard.UID)
|
||||
}
|
||||
|
||||
// Delete all public dashboards in the folders
|
||||
err = s.publicDashboardService.DeleteByDashboardUIDs(ctx, cmd.OrgID, dashboardUIDs)
|
||||
if err != nil {
|
||||
return folder.ErrInternal.Errorf("failed to delete public dashboards: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = s.unifiedStore.Delete(ctx, folders, cmd.OrgID)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
clientrest "k8s.io/client-go/rest"
|
||||
@@ -31,6 +32,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
ngstore "github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/publicdashboards"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
@@ -200,21 +202,23 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) {
|
||||
}
|
||||
|
||||
featuresArr := []any{
|
||||
featuremgmt.FlagKubernetesFolders,
|
||||
featuremgmt.FlagKubernetesFoldersServiceV2}
|
||||
features := featuremgmt.WithFeatures(featuresArr...)
|
||||
dashboardStore := dashboards.NewFakeDashboardStore(t)
|
||||
publicDashboardService := publicdashboards.NewFakePublicDashboardServiceWrapper(t)
|
||||
|
||||
folderService := &Service{
|
||||
log: slog.New(logtest.NewTestHandler(t)).With("logger", "test-folder-service"),
|
||||
unifiedStore: unifiedStore,
|
||||
features: features,
|
||||
bus: bus.ProvideBus(tracing.InitializeTracerForTest()),
|
||||
// db: db,
|
||||
accessControl: acimpl.ProvideAccessControl(features),
|
||||
registry: make(map[string]folder.RegistryService),
|
||||
metrics: newFoldersMetrics(nil),
|
||||
tracer: tracing.InitializeTracerForTest(),
|
||||
k8sclient: k8sHandler,
|
||||
log: slog.New(logtest.NewTestHandler(t)).With("logger", "test-folder-service"),
|
||||
unifiedStore: unifiedStore,
|
||||
features: features,
|
||||
bus: bus.ProvideBus(tracing.InitializeTracerForTest()),
|
||||
accessControl: acimpl.ProvideAccessControl(features),
|
||||
registry: make(map[string]folder.RegistryService),
|
||||
metrics: newFoldersMetrics(nil),
|
||||
tracer: tracing.InitializeTracerForTest(),
|
||||
k8sclient: k8sHandler,
|
||||
dashboardStore: dashboardStore,
|
||||
publicDashboardService: publicDashboardService,
|
||||
}
|
||||
|
||||
require.NoError(t, folderService.RegisterService(alertingStore))
|
||||
@@ -346,6 +350,9 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("When deleting folder by uid should not return access denied error - ForceDeleteRules false", func(t *testing.T) {
|
||||
dashboardStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{}, nil)
|
||||
publicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
err := folderService.Delete(ctx, &folder.DeleteFolderCommand{
|
||||
UID: "deletefolder",
|
||||
OrgID: orgID,
|
||||
|
||||
@@ -18,6 +18,7 @@ var _ DashboardGuardian = new(accessControlDashboardGuardian)
|
||||
func NewAccessControlDashboardGuardian(
|
||||
ctx context.Context, cfg *setting.Cfg, dashboardId int64, user identity.Requester,
|
||||
ac accesscontrol.AccessControl, dashboardService dashboards.DashboardService,
|
||||
foldersService folder.Service, logger log.Logger,
|
||||
) (DashboardGuardian, error) {
|
||||
var dashboard *dashboards.Dashboard
|
||||
if dashboardId != 0 {
|
||||
@@ -37,6 +38,7 @@ func NewAccessControlDashboardGuardian(
|
||||
}
|
||||
|
||||
if dashboard != nil && dashboard.IsFolder {
|
||||
logger.Info("using dashboard guardian for folder", "folder", dashboard.UID)
|
||||
return &accessControlFolderGuardian{
|
||||
accessControlBaseGuardian: accessControlBaseGuardian{
|
||||
ctx: ctx,
|
||||
@@ -58,68 +60,22 @@ func NewAccessControlDashboardGuardian(
|
||||
user: user,
|
||||
ac: ac,
|
||||
dashboardService: dashboardService,
|
||||
},
|
||||
dashboard: dashboard,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewAccessControlDashboardGuardianByDashboard creates a dashboard guardian by the provided dashboardUID.
|
||||
func NewAccessControlDashboardGuardianByUID(
|
||||
ctx context.Context, cfg *setting.Cfg, dashboardUID string, user identity.Requester,
|
||||
ac accesscontrol.AccessControl, dashboardService dashboards.DashboardService,
|
||||
) (DashboardGuardian, error) {
|
||||
var dashboard *dashboards.Dashboard
|
||||
if dashboardUID != "" {
|
||||
q := &dashboards.GetDashboardQuery{
|
||||
UID: dashboardUID,
|
||||
OrgID: user.GetOrgID(),
|
||||
}
|
||||
|
||||
qResult, err := dashboardService.GetDashboard(ctx, q)
|
||||
if err != nil {
|
||||
if errors.Is(err, dashboards.ErrDashboardNotFound) {
|
||||
return nil, ErrGuardianDashboardNotFound.Errorf("failed to get dashboard by UID: %w", err)
|
||||
}
|
||||
return nil, ErrGuardianGetDashboardFailure.Errorf("failed to get dashboard by UID: %w", err)
|
||||
}
|
||||
dashboard = qResult
|
||||
}
|
||||
|
||||
if dashboard != nil && dashboard.IsFolder {
|
||||
return &accessControlFolderGuardian{
|
||||
accessControlBaseGuardian: accessControlBaseGuardian{
|
||||
ctx: ctx,
|
||||
cfg: cfg,
|
||||
log: log.New("folder.permissions"),
|
||||
user: user,
|
||||
ac: ac,
|
||||
dashboardService: dashboardService,
|
||||
},
|
||||
folder: dashboards.FromDashboard(dashboard),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &accessControlDashboardGuardian{
|
||||
accessControlBaseGuardian: accessControlBaseGuardian{
|
||||
cfg: cfg,
|
||||
ctx: ctx,
|
||||
log: log.New("dashboard.permissions"),
|
||||
user: user,
|
||||
ac: ac,
|
||||
dashboardService: dashboardService,
|
||||
folderService: foldersService,
|
||||
},
|
||||
dashboard: dashboard,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewAccessControlDashboardGuardianByDashboard creates a dashboard guardian by the provided dashboard.
|
||||
// This constructor should be preferred over the other two if the dashboard in available
|
||||
// This constructor should be preferred over the other two if the dashboard is available
|
||||
// since it avoids querying the database for fetching the dashboard.
|
||||
func NewAccessControlDashboardGuardianByDashboard(
|
||||
ctx context.Context, cfg *setting.Cfg, dashboard *dashboards.Dashboard, user identity.Requester,
|
||||
ac accesscontrol.AccessControl, dashboardService dashboards.DashboardService,
|
||||
ac accesscontrol.AccessControl, dashboardService dashboards.DashboardService, folderService folder.Service,
|
||||
logger log.Logger,
|
||||
) (DashboardGuardian, error) {
|
||||
if dashboard != nil && dashboard.IsFolder {
|
||||
logger.Info("using by dashboard guardian for folder", "folder", dashboard.UID)
|
||||
return &accessControlFolderGuardian{
|
||||
accessControlBaseGuardian: accessControlBaseGuardian{
|
||||
ctx: ctx,
|
||||
@@ -128,6 +84,7 @@ func NewAccessControlDashboardGuardianByDashboard(
|
||||
user: user,
|
||||
ac: ac,
|
||||
dashboardService: dashboardService,
|
||||
folderService: folderService,
|
||||
},
|
||||
folder: dashboards.FromDashboard(dashboard),
|
||||
}, nil
|
||||
@@ -141,16 +98,35 @@ func NewAccessControlDashboardGuardianByDashboard(
|
||||
user: user,
|
||||
ac: ac,
|
||||
dashboardService: dashboardService,
|
||||
folderService: folderService,
|
||||
},
|
||||
dashboard: dashboard,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewAccessControlFolderGuardian creates a folder guardian by the provided folder.
|
||||
func NewAccessControlFolderGuardian(
|
||||
ctx context.Context, cfg *setting.Cfg, f *folder.Folder, user identity.Requester,
|
||||
ac accesscontrol.AccessControl, dashboardService dashboards.DashboardService,
|
||||
// NewAccessControlFolderGuardianByUID creates a folder guardian by the provided folderUID.
|
||||
func NewAccessControlFolderGuardianByUID(
|
||||
ctx context.Context, cfg *setting.Cfg, folderUID string, user identity.Requester,
|
||||
ac accesscontrol.AccessControl, dashboardService dashboards.DashboardService, foldersService folder.Service,
|
||||
) (DashboardGuardian, error) {
|
||||
var f *folder.Folder
|
||||
if folderUID != "" {
|
||||
q := &folder.GetFolderQuery{
|
||||
UID: &folderUID,
|
||||
OrgID: user.GetOrgID(),
|
||||
SignedInUser: user,
|
||||
}
|
||||
|
||||
qResult, err := foldersService.Get(ctx, q)
|
||||
if err != nil {
|
||||
if errors.Is(err, dashboards.ErrFolderNotFound) {
|
||||
return nil, ErrGuardianFolderNotFound.Errorf("failed to get folder by UID: %w", err)
|
||||
}
|
||||
return nil, ErrGuardianGetFolderFailure.Errorf("failed to get folder by UID: %w", err)
|
||||
}
|
||||
f = qResult
|
||||
}
|
||||
|
||||
return &accessControlFolderGuardian{
|
||||
accessControlBaseGuardian: accessControlBaseGuardian{
|
||||
ctx: ctx,
|
||||
@@ -159,6 +135,44 @@ func NewAccessControlFolderGuardian(
|
||||
user: user,
|
||||
ac: ac,
|
||||
dashboardService: dashboardService,
|
||||
folderService: foldersService,
|
||||
},
|
||||
folder: f,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewAccessControlFolderGuardian creates a folder guardian by the provided folder.
|
||||
func NewAccessControlFolderGuardian(
|
||||
ctx context.Context, cfg *setting.Cfg, f *folder.Folder, user identity.Requester,
|
||||
ac accesscontrol.AccessControl, orgID int64, dashboardService dashboards.DashboardService,
|
||||
folderService folder.Service,
|
||||
) (DashboardGuardian, error) {
|
||||
if f.UID == "" { // nolint:staticcheck
|
||||
query := &folder.GetFolderQuery{
|
||||
ID: &f.ID, // nolint:staticcheck
|
||||
OrgID: orgID,
|
||||
SignedInUser: user,
|
||||
}
|
||||
|
||||
folder, err := folderService.Get(ctx, query)
|
||||
if err != nil {
|
||||
if errors.Is(err, dashboards.ErrFolderNotFound) {
|
||||
return nil, ErrGuardianFolderNotFound.Errorf("failed to get folder: %w", err)
|
||||
}
|
||||
return nil, ErrGuardianGetFolderFailure.Errorf("failed to get folder: %w", err)
|
||||
}
|
||||
f = folder
|
||||
}
|
||||
|
||||
return &accessControlFolderGuardian{
|
||||
accessControlBaseGuardian: accessControlBaseGuardian{
|
||||
ctx: ctx,
|
||||
cfg: cfg,
|
||||
log: log.New("folder.permissions"),
|
||||
user: user,
|
||||
ac: ac,
|
||||
dashboardService: dashboardService,
|
||||
folderService: folderService,
|
||||
},
|
||||
folder: f,
|
||||
}, nil
|
||||
@@ -171,6 +185,7 @@ type accessControlBaseGuardian struct {
|
||||
user identity.Requester
|
||||
ac accesscontrol.AccessControl
|
||||
dashboardService dashboards.DashboardService
|
||||
folderService folder.Service
|
||||
}
|
||||
|
||||
type accessControlDashboardGuardian struct {
|
||||
@@ -353,24 +368,24 @@ func (a *accessControlFolderGuardian) evaluate(evaluator accesscontrol.Evaluator
|
||||
return ok, err
|
||||
}
|
||||
|
||||
func (a *accessControlDashboardGuardian) loadParentFolder(folderID int64) (*dashboards.Dashboard, error) {
|
||||
func (a *accessControlDashboardGuardian) loadParentFolder(folderID int64) (*folder.Folder, error) {
|
||||
if folderID == 0 {
|
||||
return &dashboards.Dashboard{UID: accesscontrol.GeneralFolderUID}, nil
|
||||
return &folder.Folder{UID: accesscontrol.GeneralFolderUID, OrgID: a.user.GetOrgID()}, nil
|
||||
}
|
||||
folderQuery := &dashboards.GetDashboardQuery{ID: folderID, OrgID: a.user.GetOrgID()}
|
||||
folderQueryResult, err := a.dashboardService.GetDashboard(a.ctx, folderQuery)
|
||||
folderQuery := &folder.GetFolderQuery{ID: &folderID, OrgID: a.user.GetOrgID(), SignedInUser: a.user}
|
||||
folderQueryResult, err := a.folderService.Get(a.ctx, folderQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return folderQueryResult, nil
|
||||
}
|
||||
|
||||
func (a *accessControlFolderGuardian) loadParentFolder(folderID int64) (*dashboards.Dashboard, error) {
|
||||
func (a *accessControlFolderGuardian) loadParentFolder(folderID int64) (*folder.Folder, error) {
|
||||
if folderID == 0 {
|
||||
return &dashboards.Dashboard{UID: accesscontrol.GeneralFolderUID}, nil
|
||||
return &folder.Folder{UID: accesscontrol.GeneralFolderUID, OrgID: a.user.GetOrgID()}, nil
|
||||
}
|
||||
folderQuery := &dashboards.GetDashboardQuery{ID: folderID, OrgID: a.user.GetOrgID()}
|
||||
folderQueryResult, err := a.dashboardService.GetDashboard(a.ctx, folderQuery)
|
||||
folderQuery := &folder.GetFolderQuery{ID: &folderID, OrgID: a.user.GetOrgID(), SignedInUser: a.user}
|
||||
folderQueryResult, err := a.folderService.Get(a.ctx, folderQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
@@ -976,7 +977,7 @@ func setupAccessControlGuardianTest(
|
||||
userPermissions[orgID][p.Action] = append(userPermissions[orgID][p.Action], p.Scope)
|
||||
}
|
||||
|
||||
g, err := NewAccessControlDashboardGuardianByDashboard(context.Background(), cfg, d, &user.SignedInUser{OrgID: orgID, Permissions: userPermissions}, ac, fakeDashboardService)
|
||||
g, err := NewAccessControlDashboardGuardianByDashboard(context.Background(), cfg, d, &user.SignedInUser{OrgID: orgID, Permissions: userPermissions}, ac, fakeDashboardService, folderSvc, log.NewNopLogger())
|
||||
require.NoError(t, err)
|
||||
return g
|
||||
}
|
||||
@@ -15,6 +15,7 @@ var (
|
||||
ErrGuardianGetDashboardFailure = errutil.Internal("guardian.getDashboardFailure", errutil.WithPublicMessage("Failed to get dashboard"))
|
||||
ErrGuardianDashboardNotFound = errutil.NotFound("guardian.dashboardNotFound")
|
||||
ErrGuardianFolderNotFound = errutil.NotFound("guardian.folderNotFound")
|
||||
ErrGuardianGetFolderFailure = errutil.Internal("guardian.getFolderFailure", errutil.WithPublicMessage("Failed to get folder"))
|
||||
)
|
||||
|
||||
// DashboardGuardian to be used for guard against operations without access on dashboard and acl
|
||||
@@ -33,18 +34,18 @@ var New = func(ctx context.Context, dashId int64, orgId int64, user identity.Req
|
||||
panic("no guardian factory implementation provided")
|
||||
}
|
||||
|
||||
// NewByUID factory for creating a new dashboard guardian instance
|
||||
// When using access control this function is replaced on startup and the AccessControlDashboardGuardian is returned
|
||||
var NewByUID = func(ctx context.Context, dashUID string, orgId int64, user identity.Requester) (DashboardGuardian, error) {
|
||||
panic("no guardian factory implementation provided")
|
||||
}
|
||||
|
||||
// NewByDashboard factory for creating a new dashboard guardian instance
|
||||
// When using access control this function is replaced on startup and the AccessControlDashboardGuardian is returned
|
||||
var NewByDashboard = func(ctx context.Context, dash *dashboards.Dashboard, orgId int64, user identity.Requester) (DashboardGuardian, error) {
|
||||
panic("no guardian factory implementation provided")
|
||||
}
|
||||
|
||||
// NewByFolderUID factory for creating a new folder guardian instance
|
||||
// When using access control this function is replaced on startup and the AccessControlDashboardGuardian is returned
|
||||
var NewByFolderUID = func(ctx context.Context, folderUID string, orgId int64, user identity.Requester) (DashboardGuardian, error) {
|
||||
panic("no guardian factory implementation provided")
|
||||
}
|
||||
|
||||
// NewByFolder factory for creating a new folder guardian instance
|
||||
// When using access control this function is replaced on startup and the AccessControlDashboardGuardian is returned
|
||||
var NewByFolder = func(ctx context.Context, f *folder.Folder, orgId int64, user identity.Requester) (DashboardGuardian, error) {
|
||||
@@ -107,14 +108,6 @@ func MockDashboardGuardian(mock *FakeDashboardGuardian) {
|
||||
mock.User = user
|
||||
return mock, nil
|
||||
}
|
||||
|
||||
NewByUID = func(_ context.Context, dashUID string, orgId int64, user identity.Requester) (DashboardGuardian, error) {
|
||||
mock.OrgID = orgId
|
||||
mock.DashUID = dashUID
|
||||
mock.User = user
|
||||
return mock, nil
|
||||
}
|
||||
|
||||
NewByDashboard = func(_ context.Context, dash *dashboards.Dashboard, orgId int64, user identity.Requester) (DashboardGuardian, error) {
|
||||
mock.OrgID = orgId
|
||||
mock.DashUID = dash.UID
|
||||
@@ -123,6 +116,13 @@ func MockDashboardGuardian(mock *FakeDashboardGuardian) {
|
||||
return mock, nil
|
||||
}
|
||||
|
||||
NewByFolderUID = func(_ context.Context, folderUID string, orgId int64, user identity.Requester) (DashboardGuardian, error) {
|
||||
mock.OrgID = orgId
|
||||
mock.DashUID = folderUID
|
||||
mock.User = user
|
||||
return mock, nil
|
||||
}
|
||||
|
||||
NewByFolder = func(_ context.Context, f *folder.Folder, orgId int64, user identity.Requester) (DashboardGuardian, error) {
|
||||
mock.OrgID = orgId
|
||||
mock.DashUID = f.UID
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
@@ -16,28 +17,29 @@ type Provider struct{}
|
||||
func ProvideService(
|
||||
cfg *setting.Cfg, ac accesscontrol.AccessControl,
|
||||
dashboardService dashboards.DashboardService, teamService team.Service,
|
||||
folderService folder.Service,
|
||||
) *Provider {
|
||||
// TODO: Fix this hack, see https://github.com/grafana/grafana-enterprise/issues/2935
|
||||
InitAccessControlGuardian(cfg, ac, dashboardService)
|
||||
InitAccessControlGuardian(cfg, ac, dashboardService, folderService, log.New("guardian"))
|
||||
return &Provider{}
|
||||
}
|
||||
|
||||
func InitAccessControlGuardian(
|
||||
cfg *setting.Cfg, ac accesscontrol.AccessControl, dashboardService dashboards.DashboardService,
|
||||
cfg *setting.Cfg, ac accesscontrol.AccessControl, dashboardService dashboards.DashboardService, folderService folder.Service, logger log.Logger,
|
||||
) {
|
||||
New = func(ctx context.Context, dashId int64, orgId int64, user identity.Requester) (DashboardGuardian, error) {
|
||||
return NewAccessControlDashboardGuardian(ctx, cfg, dashId, user, ac, dashboardService)
|
||||
}
|
||||
|
||||
NewByUID = func(ctx context.Context, dashUID string, orgId int64, user identity.Requester) (DashboardGuardian, error) {
|
||||
return NewAccessControlDashboardGuardianByUID(ctx, cfg, dashUID, user, ac, dashboardService)
|
||||
return NewAccessControlDashboardGuardian(ctx, cfg, dashId, user, ac, dashboardService, folderService, logger)
|
||||
}
|
||||
|
||||
NewByDashboard = func(ctx context.Context, dash *dashboards.Dashboard, orgId int64, user identity.Requester) (DashboardGuardian, error) {
|
||||
return NewAccessControlDashboardGuardianByDashboard(ctx, cfg, dash, user, ac, dashboardService)
|
||||
return NewAccessControlDashboardGuardianByDashboard(ctx, cfg, dash, user, ac, dashboardService, folderService, logger)
|
||||
}
|
||||
|
||||
NewByFolderUID = func(ctx context.Context, folderUID string, orgId int64, user identity.Requester) (DashboardGuardian, error) {
|
||||
return NewAccessControlFolderGuardianByUID(ctx, cfg, folderUID, user, ac, dashboardService, folderService)
|
||||
}
|
||||
|
||||
NewByFolder = func(ctx context.Context, f *folder.Folder, orgId int64, user identity.Requester) (DashboardGuardian, error) {
|
||||
return NewAccessControlFolderGuardian(ctx, cfg, f, user, ac, dashboardService)
|
||||
return NewAccessControlFolderGuardian(ctx, cfg, f, user, ac, orgId, dashboardService, folderService)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
"github.com/grafana/grafana/pkg/services/libraryelements/model"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
@@ -41,7 +42,7 @@ func (l *LibraryElementService) requireEditPermissionsOnFolderUID(ctx context.Co
|
||||
return dashboards.ErrFolderAccessDenied
|
||||
}
|
||||
|
||||
g, err := guardian.NewByUID(ctx, folderUID, user.GetOrgID(), user)
|
||||
g, err := guardian.NewByFolderUID(ctx, folderUID, user.GetOrgID(), user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -67,7 +68,10 @@ func (l *LibraryElementService) requireEditPermissionsOnFolder(ctx context.Conte
|
||||
return dashboards.ErrFolderAccessDenied
|
||||
}
|
||||
|
||||
g, err := guardian.New(ctx, folderID, user.GetOrgID(), user)
|
||||
g, err := guardian.NewByFolder(ctx, &folder.Folder{
|
||||
ID: folderID,
|
||||
OrgID: user.GetOrgID(),
|
||||
}, user.GetOrgID(), user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -88,7 +92,10 @@ func (l *LibraryElementService) requireViewPermissionsOnFolder(ctx context.Conte
|
||||
return nil
|
||||
}
|
||||
|
||||
g, err := guardian.New(ctx, folderID, user.GetOrgID(), user)
|
||||
g, err := guardian.NewByFolder(ctx, &folder.Folder{
|
||||
ID: folderID,
|
||||
OrgID: user.GetOrgID(),
|
||||
}, user.GetOrgID(), user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -539,7 +539,7 @@ func TestGetAllLibraryElements(t *testing.T) {
|
||||
|
||||
scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and folderFilterUIDs is set to existing folders, it should succeed and the result should be correct",
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
newFolder := createFolder(t, sc, "NewFolder")
|
||||
newFolder := createFolder(t, sc, "NewFolder", nil)
|
||||
// nolint:staticcheck
|
||||
command := getCreatePanelCommand(newFolder.ID, newFolder.UID, "Text - Library Panel2")
|
||||
sc.reqContext.Req.Body = mockRequestBody(command)
|
||||
@@ -608,7 +608,7 @@ func TestGetAllLibraryElements(t *testing.T) {
|
||||
|
||||
scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and folderFilter is set to a nonexistent folders, it should succeed and the result should be correct",
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
newFolder := createFolder(t, sc, "NewFolder")
|
||||
newFolder := createFolder(t, sc, "NewFolder", nil)
|
||||
// nolint:staticcheck
|
||||
command := getCreatePanelCommand(newFolder.ID, sc.folder.UID, "Text - Library Panel2")
|
||||
sc.reqContext.Req.Body = mockRequestBody(command)
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestPatchLibraryElement(t *testing.T) {
|
||||
|
||||
scenarioWithPanel(t, "When an admin tries to patch a library panel that exists, it should succeed",
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
newFolder := createFolder(t, sc, "NewFolder")
|
||||
newFolder := createFolder(t, sc, "NewFolder", nil)
|
||||
cmd := model.PatchLibraryElementCommand{
|
||||
FolderID: newFolder.ID, // nolint:staticcheck
|
||||
FolderUID: &newFolder.UID,
|
||||
@@ -91,7 +91,7 @@ func TestPatchLibraryElement(t *testing.T) {
|
||||
|
||||
scenarioWithPanel(t, "When an admin tries to patch a library panel with folder only, it should change folder successfully and return correct result",
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
newFolder := createFolder(t, sc, "NewFolder")
|
||||
newFolder := createFolder(t, sc, "NewFolder", nil)
|
||||
cmd := model.PatchLibraryElementCommand{
|
||||
FolderID: newFolder.ID, // nolint:staticcheck
|
||||
FolderUID: &newFolder.UID,
|
||||
@@ -335,7 +335,7 @@ func TestPatchLibraryElement(t *testing.T) {
|
||||
|
||||
scenarioWithPanel(t, "When an admin tries to patch a library panel with a folder where a library panel with the same name already exists, it should fail",
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
newFolder := createFolder(t, sc, "NewFolder")
|
||||
newFolder := createFolder(t, sc, "NewFolder", nil)
|
||||
// nolint:staticcheck
|
||||
command := getCreatePanelCommand(newFolder.ID, newFolder.UID, "Text - Library Panel")
|
||||
sc.ctx.Req.Body = mockRequestBody(command)
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestLibraryElementPermissionsGeneralFolder(t *testing.T) {
|
||||
|
||||
testScenario(t, fmt.Sprintf("When %s tries to patch a library panel by moving it to the General folder, it should return correct status", testCase.role),
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
folder := createFolder(t, sc, "Folder")
|
||||
folder := createFolder(t, sc, "Folder", nil)
|
||||
// nolint:staticcheck
|
||||
command := getCreatePanelCommand(folder.ID, folder.UID, "Library Panel Name")
|
||||
sc.reqContext.Req.Body = mockRequestBody(command)
|
||||
@@ -56,7 +56,7 @@ func TestLibraryElementPermissionsGeneralFolder(t *testing.T) {
|
||||
|
||||
testScenario(t, fmt.Sprintf("When %s tries to patch a library panel by moving it from the General folder, it should return correct status", testCase.role),
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
folder := createFolder(t, sc, "Folder")
|
||||
folder := createFolder(t, sc, "Folder", nil)
|
||||
command := getCreatePanelCommand(0, "", "Library Panel Name")
|
||||
sc.reqContext.Req.Body = mockRequestBody(command)
|
||||
resp := sc.service.createHandler(sc.reqContext)
|
||||
@@ -178,7 +178,7 @@ func TestLibraryElementCreatePermissions(t *testing.T) {
|
||||
for _, testCase := range accessCases {
|
||||
testScenario(t, testCase.desc,
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
folder := createFolder(t, sc, "Folder")
|
||||
folder := createFolder(t, sc, "Folder", nil)
|
||||
sc.reqContext.SignedInUser.Permissions = map[int64]map[string][]string{
|
||||
1: testCase.permissions,
|
||||
}
|
||||
@@ -235,14 +235,14 @@ func TestLibraryElementPatchPermissions(t *testing.T) {
|
||||
for _, testCase := range accessCases {
|
||||
testScenario(t, testCase.desc,
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
fromFolder := createFolder(t, sc, "FromFolder")
|
||||
fromFolder := createFolder(t, sc, "FromFolder", nil)
|
||||
// nolint:staticcheck
|
||||
command := getCreatePanelCommand(fromFolder.ID, fromFolder.UID, "Library Panel Name")
|
||||
sc.reqContext.Req.Body = mockRequestBody(command)
|
||||
resp := sc.service.createHandler(sc.reqContext)
|
||||
result := validateAndUnMarshalResponse(t, resp)
|
||||
|
||||
toFolder := createFolder(t, sc, "ToFolder")
|
||||
toFolder := createFolder(t, sc, "ToFolder", nil)
|
||||
|
||||
sc.reqContext.SignedInUser.Permissions = map[int64]map[string][]string{
|
||||
1: testCase.permissions,
|
||||
@@ -268,6 +268,7 @@ func TestLibraryElementDeletePermissions(t *testing.T) {
|
||||
desc: "can delete library elements when granted write access to the correct folder",
|
||||
permissions: map[string][]string{
|
||||
dashboards.ActionFoldersWrite: {dashboards.ScopeFoldersProvider.GetResourceScopeUID("Folder")},
|
||||
dashboards.ActionFoldersRead: {dashboards.ScopeFoldersProvider.GetResourceScopeUID("Folder")},
|
||||
},
|
||||
status: http.StatusOK,
|
||||
},
|
||||
@@ -275,6 +276,7 @@ func TestLibraryElementDeletePermissions(t *testing.T) {
|
||||
desc: "can delete library elements when granted write access to all folders",
|
||||
permissions: map[string][]string{
|
||||
dashboards.ActionFoldersWrite: {dashboards.ScopeFoldersProvider.GetResourceAllScope()},
|
||||
dashboards.ActionFoldersRead: {dashboards.ScopeFoldersProvider.GetResourceAllScope()},
|
||||
},
|
||||
status: http.StatusOK,
|
||||
},
|
||||
@@ -282,6 +284,7 @@ func TestLibraryElementDeletePermissions(t *testing.T) {
|
||||
desc: "can't delete library elements when granted write access to the wrong folder",
|
||||
permissions: map[string][]string{
|
||||
dashboards.ActionFoldersWrite: {dashboards.ScopeFoldersProvider.GetResourceScopeUID("Other_folder")},
|
||||
dashboards.ActionFoldersRead: {dashboards.ScopeFoldersProvider.GetResourceScopeUID("Other_folder")},
|
||||
},
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
@@ -297,7 +300,7 @@ func TestLibraryElementDeletePermissions(t *testing.T) {
|
||||
for _, testCase := range accessCases {
|
||||
testScenario(t, testCase.desc,
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
folder := createFolder(t, sc, "Folder")
|
||||
folder := createFolder(t, sc, "Folder", sc.service.folderService)
|
||||
// nolint:staticcheck
|
||||
command := getCreatePanelCommand(folder.ID, folder.UID, "Library Panel Name")
|
||||
sc.reqContext.Req.Body = mockRequestBody(command)
|
||||
@@ -327,7 +330,7 @@ func TestLibraryElementsWithMissingFolders(t *testing.T) {
|
||||
|
||||
testScenario(t, "When a user tries to patch a library panel by moving it to a folder that doesn't exist, it should fail",
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
folder := createFolder(t, sc, "Folder")
|
||||
folder := createFolder(t, sc, "Folder", nil)
|
||||
// nolint:staticcheck
|
||||
command := getCreatePanelCommand(folder.ID, folder.UID, "Library Panel Name")
|
||||
sc.reqContext.Req.Body = mockRequestBody(command)
|
||||
@@ -368,7 +371,7 @@ func TestLibraryElementsGetPermissions(t *testing.T) {
|
||||
for _, testCase := range getCases {
|
||||
testScenario(t, testCase.desc,
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
folder := createFolder(t, sc, "Folder")
|
||||
folder := createFolder(t, sc, "Folder", nil)
|
||||
// nolint:staticcheck
|
||||
cmd := getCreatePanelCommand(folder.ID, folder.UID, "Library Panel")
|
||||
sc.reqContext.Req.Body = mockRequestBody(cmd)
|
||||
@@ -419,7 +422,7 @@ func TestLibraryElementsGetAllPermissions(t *testing.T) {
|
||||
testScenario(t, testCase.desc,
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
for i := 1; i <= 2; i++ {
|
||||
folder := createFolder(t, sc, fmt.Sprintf("Folder%d", i))
|
||||
folder := createFolder(t, sc, fmt.Sprintf("Folder%d", i), nil)
|
||||
// nolint:staticcheck
|
||||
cmd := getCreatePanelCommand(folder.ID, folder.UID, fmt.Sprintf("Library Panel %d", i))
|
||||
sc.reqContext.Req.Body = mockRequestBody(cmd)
|
||||
|
||||
@@ -32,7 +32,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/folder/folderimpl"
|
||||
"github.com/grafana/grafana/pkg/services/folder/foldertest"
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
"github.com/grafana/grafana/pkg/services/libraryelements/model"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
@@ -99,7 +98,7 @@ func TestDeleteLibraryPanelsInFolder(t *testing.T) {
|
||||
scenarioWithPanel(t, "When an admin tries to delete a folder uid that doesn't exist, it should fail",
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
err := sc.service.DeleteLibraryElementsInFolder(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, sc.folder.UID+"xxxx")
|
||||
require.EqualError(t, err, dashboards.ErrFolderNotFound.Error())
|
||||
require.EqualError(t, err, guardian.ErrGuardianFolderNotFound.Errorf("failed to get folder by UID: %w", dashboards.ErrFolderNotFound).Error())
|
||||
})
|
||||
|
||||
scenarioWithPanel(t, "When an admin tries to delete a folder that contains disconnected elements, it should delete all disconnected elements too",
|
||||
@@ -300,17 +299,19 @@ func createDashboard(t *testing.T, sqlStore db.DB, user user.SignedInUser, dash
|
||||
require.NoError(t, err)
|
||||
ac := actest.FakeAccessControl{ExpectedEvaluate: true}
|
||||
folderPermissions := acmock.NewMockedPermissionsService()
|
||||
folderPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil)
|
||||
dashboardPermissions := acmock.NewMockedPermissionsService()
|
||||
dashboardPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil)
|
||||
folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore)
|
||||
var expectedFolder *folder.Folder
|
||||
if dash.FolderUID != "" || dash.FolderID != 0 { // nolint:staticcheck
|
||||
expectedFolder = &folder.Folder{ID: folderID, UID: folderUID}
|
||||
}
|
||||
fStore := folderimpl.ProvideStore(sqlStore)
|
||||
folderSvc := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore,
|
||||
folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest())
|
||||
_, err = folderSvc.Create(context.Background(), &folder.CreateFolderCommand{UID: folderUID, SignedInUser: &user, Title: folderUID + "-title"})
|
||||
require.NoError(t, err)
|
||||
service, err := dashboardservice.ProvideDashboardServiceImpl(
|
||||
cfg, dashboardStore, folderStore,
|
||||
features, folderPermissions, ac,
|
||||
&foldertest.FakeService{ExpectedFolder: expectedFolder},
|
||||
folderSvc,
|
||||
folder.NewFakeStore(),
|
||||
nil,
|
||||
nil,
|
||||
@@ -318,6 +319,7 @@ func createDashboard(t *testing.T, sqlStore db.DB, user user.SignedInUser, dash
|
||||
nil,
|
||||
quotaService,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
service.RegisterDashboardPermissions(dashboardPermissions)
|
||||
@@ -327,22 +329,24 @@ func createDashboard(t *testing.T, sqlStore db.DB, user user.SignedInUser, dash
|
||||
return dashboard
|
||||
}
|
||||
|
||||
func createFolder(t *testing.T, sc scenarioContext, title string) *folder.Folder {
|
||||
func createFolder(t *testing.T, sc scenarioContext, title string, folderSvc folder.Service) *folder.Folder {
|
||||
t.Helper()
|
||||
|
||||
features := featuremgmt.WithFeatures()
|
||||
cfg := setting.NewCfg()
|
||||
ac := actest.FakeAccessControl{ExpectedEvaluate: true}
|
||||
dashboardStore, err := database.ProvideDashboardStore(sc.sqlStore, cfg, features, tagimpl.ProvideService(sc.sqlStore))
|
||||
require.NoError(t, err)
|
||||
if folderSvc == nil {
|
||||
features := featuremgmt.WithFeatures()
|
||||
cfg := setting.NewCfg()
|
||||
ac := actest.FakeAccessControl{ExpectedEvaluate: true}
|
||||
dashboardStore, err := database.ProvideDashboardStore(sc.sqlStore, cfg, features, tagimpl.ProvideService(sc.sqlStore))
|
||||
require.NoError(t, err)
|
||||
|
||||
folderStore := folderimpl.ProvideDashboardFolderStore(sc.sqlStore)
|
||||
store := folderimpl.ProvideStore(sc.sqlStore)
|
||||
s := folderimpl.ProvideService(store, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, sc.sqlStore,
|
||||
features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest())
|
||||
t.Logf("Creating folder with title and UID %q", title)
|
||||
folderStore := folderimpl.ProvideDashboardFolderStore(sc.sqlStore)
|
||||
store := folderimpl.ProvideStore(sc.sqlStore)
|
||||
folderSvc = folderimpl.ProvideService(store, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, sc.sqlStore,
|
||||
features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest())
|
||||
t.Logf("Creating folder with title and UID %q", title)
|
||||
}
|
||||
ctx := identity.WithRequester(context.Background(), &sc.user)
|
||||
folder, err := s.Create(ctx, &folder.CreateFolderCommand{
|
||||
folder, err := folderSvc.Create(ctx, &folder.CreateFolderCommand{
|
||||
OrgID: sc.user.OrgID, Title: title, UID: title, SignedInUser: &sc.user,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -399,15 +403,18 @@ func scenarioWithPanel(t *testing.T, desc string, fn func(t *testing.T, sc scena
|
||||
folderPermissions := acmock.NewMockedPermissionsService()
|
||||
dashboardPermissions := acmock.NewMockedPermissionsService()
|
||||
folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore)
|
||||
fStore := folderimpl.ProvideStore(sqlStore)
|
||||
folderSvc := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore,
|
||||
folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest())
|
||||
dashboardService, svcErr := dashboardservice.ProvideDashboardServiceImpl(
|
||||
cfg, dashboardStore, folderStore,
|
||||
features, folderPermissions, ac,
|
||||
foldertest.NewFakeService(), folder.NewFakeStore(),
|
||||
nil, nil, nil, nil, quotaService, nil,
|
||||
folderSvc, fStore,
|
||||
nil, nil, nil, nil, quotaService, nil, nil,
|
||||
)
|
||||
require.NoError(t, svcErr)
|
||||
dashboardService.RegisterDashboardPermissions(dashboardPermissions)
|
||||
guardian.InitAccessControlGuardian(cfg, ac, dashboardService)
|
||||
guardian.InitAccessControlGuardian(cfg, ac, dashboardService, folderSvc, log.NewNopLogger())
|
||||
|
||||
testScenario(t, desc, func(t *testing.T, sc scenarioContext) {
|
||||
// nolint:staticcheck
|
||||
@@ -462,23 +469,23 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo
|
||||
folderPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil)
|
||||
dashboardPermissions := acmock.NewMockedPermissionsService()
|
||||
folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore)
|
||||
fStore := folderimpl.ProvideStore(sqlStore)
|
||||
folderSvc := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore,
|
||||
folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest())
|
||||
dashService, dashSvcErr := dashboardservice.ProvideDashboardServiceImpl(
|
||||
cfg, dashboardStore, folderStore,
|
||||
features, folderPermissions, ac,
|
||||
foldertest.NewFakeService(), folder.NewFakeStore(),
|
||||
nil, nil, nil, nil, quotaService, nil,
|
||||
folderSvc, fStore,
|
||||
nil, nil, nil, nil, quotaService, nil, nil,
|
||||
)
|
||||
require.NoError(t, dashSvcErr)
|
||||
dashService.RegisterDashboardPermissions(dashboardPermissions)
|
||||
guardian.InitAccessControlGuardian(cfg, ac, dashService)
|
||||
fStore := folderimpl.ProvideStore(sqlStore)
|
||||
folderSrv := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracer), dashboardStore, folderStore, sqlStore,
|
||||
features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest())
|
||||
guardian.InitAccessControlGuardian(cfg, ac, dashService, folderSvc, log.NewNopLogger())
|
||||
service := LibraryElementService{
|
||||
Cfg: cfg,
|
||||
features: featuremgmt.WithFeatures(),
|
||||
SQLStore: sqlStore,
|
||||
folderService: folderSrv,
|
||||
folderService: folderSvc,
|
||||
}
|
||||
|
||||
// deliberate difference between signed in user and user in db to make it crystal clear
|
||||
@@ -510,7 +517,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo
|
||||
},
|
||||
}
|
||||
|
||||
sc.folder = createFolder(t, sc, "ScenarioFolder")
|
||||
sc.folder = createFolder(t, sc, "ScenarioFolder", folderSvc)
|
||||
|
||||
fn(t, sc)
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/slugify"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/kinds/librarypanel"
|
||||
@@ -734,7 +735,7 @@ func createDashboard(t *testing.T, sqlStore db.DB, user *user.SignedInUser, dash
|
||||
cfg, dashboardStore, folderStore,
|
||||
features, acmock.NewMockedPermissionsService(), ac,
|
||||
foldertest.NewFakeService(), folder.NewFakeStore(),
|
||||
nil, nil, nil, nil, quotaService, nil,
|
||||
nil, nil, nil, nil, quotaService, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
service.RegisterDashboardPermissions(dashPermissionService)
|
||||
@@ -755,7 +756,7 @@ func createFolder(t *testing.T, sc scenarioContext, title string) *folder.Folder
|
||||
folderStore := folderimpl.ProvideDashboardFolderStore(sc.sqlStore)
|
||||
fStore := folderimpl.ProvideStore(sc.sqlStore)
|
||||
s := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, sc.sqlStore,
|
||||
features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest())
|
||||
features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest())
|
||||
|
||||
t.Logf("Creating folder with title and UID %q", title)
|
||||
ctx := identity.WithRequester(context.Background(), sc.user)
|
||||
@@ -823,25 +824,26 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo
|
||||
|
||||
ac := actest.FakeAccessControl{ExpectedEvaluate: true}
|
||||
dashStore := &dashboards.FakeDashboardStore{}
|
||||
dashStore.On("GetDashboard", mock.Anything, mock.Anything).Return(&dashboards.Dashboard{ID: 1}, nil)
|
||||
folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore)
|
||||
dashPermissionService := acmock.NewMockedPermissionsService()
|
||||
folderSvc := foldertest.NewFakeService()
|
||||
folderSvc.ExpectedFolder = &folder.Folder{ID: 1}
|
||||
dashService, err := dashboardservice.ProvideDashboardServiceImpl(
|
||||
cfg, dashStore, folderStore,
|
||||
features, acmock.NewMockedPermissionsService(), ac,
|
||||
foldertest.NewFakeService(), folder.NewFakeStore(),
|
||||
nil, nil, nil, nil, quotaService, nil,
|
||||
folderSvc, folder.NewFakeStore(),
|
||||
nil, nil, nil, nil, quotaService, nil, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
dashService.RegisterDashboardPermissions(dashPermissionService)
|
||||
guardian.InitAccessControlGuardian(cfg, ac, dashService)
|
||||
guardian.InitAccessControlGuardian(cfg, ac, dashService, folderSvc, log.NewNopLogger())
|
||||
|
||||
dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore))
|
||||
require.NoError(t, err)
|
||||
fStore := folderimpl.ProvideStore(sqlStore)
|
||||
|
||||
folderService := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, sqlStore,
|
||||
features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest())
|
||||
features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest())
|
||||
|
||||
elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), folderService, features, ac)
|
||||
service := LibraryPanelService{
|
||||
|
||||
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user