merge main

This commit is contained in:
Ryan McKinley committed 2025-01-24 09:02:04 +03:00
commit 1420e059d8
178 files changed
+3233 -2158

No files matched your search

+6
View File
@@ -10,6 +10,12 @@ check: {
frontend: false
backend: true
}
validation: {
operations: [
"CREATE",
"UPDATE",
]
}
schema: {
spec: {
// Generic data input that a check can receive
+9 -1
View File
@@ -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,
},
},
+46
View File
@@ -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)
},
},
},
},
}
+31
View File
@@ -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
})
}
+78
View File
@@ -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
}
+13
View File
@@ -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
}
+95
View File
@@ -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)
}
+124
View File
@@ -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. |
+3 -4
View File
@@ -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
+6 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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",
@@ -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;
}
@@ -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;
}
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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">
@@ -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);
@@ -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!)),
},
});
}
-6
View File
@@ -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
+2 -1
View File
@@ -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())
})
}
+7 -9
View File
@@ -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
View File
@@ -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
}
+3 -3
View File
@@ -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
View File
@@ -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) {
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
-41
View File
@@ -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")
+30
View File
@@ -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": ""
}
+16 -7
View File
@@ -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
}
+3 -2
View File
@@ -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":
+49 -17
View File
@@ -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 {
+45 -6
View File
@@ -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())
})
}
}
-38
View File
@@ -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])
}
-2
View File
@@ -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) {
+3 -4
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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,
+5
View File
@@ -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{
+37 -37
View File
@@ -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)
-12
View File
@@ -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)",
-2
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
93 kubernetesDashboards experimental @grafana/grafana-app-platform-squad false false true
94 kubernetesCliDashboards experimental @grafana/grafana-app-platform-squad false false false
95 kubernetesRestore experimental @grafana/grafana-app-platform-squad false false false
kubernetesFolders experimental @grafana/search-and-storage false false false
96 kubernetesFoldersServiceV2 experimental @grafana/search-and-storage false false false
grafanaAPIServerTestingWithExperimentalAPIs experimental @grafana/search-and-storage false false false
97 datasourceQueryTypes experimental @grafana/grafana-app-platform-squad false true false
98 queryService experimental @grafana/grafana-app-platform-squad false true false
99 queryServiceRewrite experimental @grafana/grafana-app-platform-squad false true false
-8
View File
@@ -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"
+77 -75
View File
@@ -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"
}
+68 -40
View File
@@ -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
}
+58 -45
View File
@@ -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,
+78 -63
View File
@@ -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
}
+14 -14
View File
@@ -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
+11 -9
View File
@@ -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)
}
}
+10 -3
View File
@@ -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