diff --git a/.betterer.results b/.betterer.results index 47b5fb03d2b..51f284e84ed 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4744,27 +4744,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], [0, 0, 0, "No untranslated strings. Wrap text with ", "3"] ], - "public/app/features/explore/QueryLibrary/QueryLibraryExpmInfo.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "3"] - ], - "public/app/features/explore/QueryLibrary/QueryTemplateForm.tsx:5381": [ - [0, 0, 0, "\'@grafana/ui/src/components/Input/Input\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] - ], - "public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "3"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "4"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "5"] - ], - "public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] - ], "public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], diff --git a/apps/advisor/kinds/checktype.cue b/apps/advisor/kinds/checktype.cue index 6a4bfa4a0df..aa31430ad2e 100644 --- a/apps/advisor/kinds/checktype.cue +++ b/apps/advisor/kinds/checktype.cue @@ -7,7 +7,7 @@ checktype: { versions: { "v0alpha1": { codegen: { - frontend: false + frontend: true backend: true } schema: { diff --git a/apps/advisor/pkg/app/checks/datasourcecheck/check.go b/apps/advisor/pkg/app/checks/datasourcecheck/check.go index 88e10a72719..e946f2e2819 100644 --- a/apps/advisor/pkg/app/checks/datasourcecheck/check.go +++ b/apps/advisor/pkg/app/checks/datasourcecheck/check.go @@ -12,7 +12,6 @@ import ( "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" ) type check struct { @@ -76,26 +75,23 @@ func (s *uidValidationStep) Description() string { return "Check if the UID of each data source is valid." } -func (s *uidValidationStep) Run(ctx context.Context, obj *advisor.CheckSpec, items []any) ([]advisor.CheckReportError, error) { - dsErrs := []advisor.CheckReportError{} - for _, i := range items { - ds, ok := i.(*datasources.DataSource) - if !ok { - return nil, fmt.Errorf("invalid item type %T", i) - } - // Data source UID validation - err := util.ValidateUID(ds.UID) - if err != nil { - dsErrs = append(dsErrs, checks.NewCheckReportError( - advisor.CheckReportErrorSeverityLow, - fmt.Sprintf("Invalid UID '%s' for data source %s", ds.UID, ds.Name), - "Check the documentation for more information.", - s.ID(), - ds.UID, - )) - } +func (s *uidValidationStep) Run(ctx context.Context, obj *advisor.CheckSpec, i any) (*advisor.CheckReportError, error) { + ds, ok := i.(*datasources.DataSource) + if !ok { + return nil, fmt.Errorf("invalid item type %T", i) } - return dsErrs, nil + // Data source UID validation + err := util.ValidateUID(ds.UID) + if err != nil { + return checks.NewCheckReportError( + advisor.CheckReportErrorSeverityLow, + fmt.Sprintf("Invalid UID '%s' for data source %s", ds.UID, ds.Name), + "Check the documentation for more information.", + s.ID(), + ds.UID, + ), nil + } + return nil, nil } type healthCheckStep struct { @@ -115,46 +111,38 @@ func (s *healthCheckStep) ID() string { return "health-check" } -func (s *healthCheckStep) Run(ctx context.Context, obj *advisor.CheckSpec, items []any) ([]advisor.CheckReportError, error) { - dsErrs := []advisor.CheckReportError{} - for _, i := range items { - ds, ok := i.(*datasources.DataSource) - if !ok { - return nil, fmt.Errorf("invalid item type %T", i) - } - - // Health check execution - requester, err := identity.GetRequester(ctx) - if err != nil { - return nil, err - } - pCtx, err := s.PluginContextProvider.GetWithDataSource(ctx, ds.Type, requester, ds) - 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 := s.PluginClient.CheckHealth(ctx, req) - if err != nil { - fmt.Println("Error checking health", err) - continue - } - if resp.Status != backend.HealthStatusOk { - dsErrs = append(dsErrs, checks.NewCheckReportError( - advisor.CheckReportErrorSeverityHigh, - fmt.Sprintf("Health check failed for %s", ds.Name), - fmt.Sprintf( - "Go to the data source configuration"+ - " and address the issues reported.", ds.UID), - s.ID(), - ds.UID, - )) - } +func (s *healthCheckStep) Run(ctx context.Context, obj *advisor.CheckSpec, i any) (*advisor.CheckReportError, error) { + ds, ok := i.(*datasources.DataSource) + if !ok { + return nil, fmt.Errorf("invalid item type %T", i) } - return dsErrs, nil + + // Health check execution + requester, err := identity.GetRequester(ctx) + if err != nil { + return nil, err + } + pCtx, err := s.PluginContextProvider.GetWithDataSource(ctx, ds.Type, requester, ds) + if err != nil { + return nil, fmt.Errorf("failed to get plugin context: %w", err) + } + req := &backend.CheckHealthRequest{ + PluginContext: pCtx, + Headers: map[string]string{}, + } + resp, err := s.PluginClient.CheckHealth(ctx, req) + if err != nil || resp.Status != backend.HealthStatusOk { + return checks.NewCheckReportError( + advisor.CheckReportErrorSeverityHigh, + fmt.Sprintf("Health check failed for %s", ds.Name), + fmt.Sprintf( + "Go to the data source configuration"+ + " and address the issues reported.", ds.UID), + s.ID(), + ds.UID, + ), nil + } + return nil, nil } type pluginContextProvider interface { diff --git a/apps/advisor/pkg/app/checks/datasourcecheck/check_test.go b/apps/advisor/pkg/app/checks/datasourcecheck/check_test.go index c360f96ad14..5cfe595de67 100644 --- a/apps/advisor/pkg/app/checks/datasourcecheck/check_test.go +++ b/apps/advisor/pkg/app/checks/datasourcecheck/check_test.go @@ -35,9 +35,13 @@ func TestCheck_Run(t *testing.T) { assert.NoError(t, err) errs := []advisor.CheckReportError{} for _, step := range check.Steps() { - stepErrs, err := step.Run(ctx, &advisor.CheckSpec{}, items) - assert.NoError(t, err) - errs = append(errs, stepErrs...) + for _, item := range items { + stepErr, err := step.Run(ctx, &advisor.CheckSpec{}, item) + assert.NoError(t, err) + if stepErr != nil { + errs = append(errs, *stepErr) + } + } } assert.NoError(t, err) @@ -65,9 +69,13 @@ func TestCheck_Run(t *testing.T) { assert.NoError(t, err) errs := []advisor.CheckReportError{} for _, step := range check.Steps() { - stepErrs, err := step.Run(ctx, &advisor.CheckSpec{}, items) - assert.NoError(t, err) - errs = append(errs, stepErrs...) + for _, item := range items { + stepErr, err := step.Run(ctx, &advisor.CheckSpec{}, item) + assert.NoError(t, err) + if stepErr != nil { + errs = append(errs, *stepErr) + } + } } assert.NoError(t, err) @@ -96,9 +104,13 @@ func TestCheck_Run(t *testing.T) { assert.NoError(t, err) errs := []advisor.CheckReportError{} for _, step := range check.Steps() { - stepErrs, err := step.Run(ctx, &advisor.CheckSpec{}, items) - assert.NoError(t, err) - errs = append(errs, stepErrs...) + for _, item := range items { + stepErr, err := step.Run(ctx, &advisor.CheckSpec{}, item) + assert.NoError(t, err) + if stepErr != nil { + errs = append(errs, *stepErr) + } + } } assert.NoError(t, err) diff --git a/apps/advisor/pkg/app/checks/ifaces.go b/apps/advisor/pkg/app/checks/ifaces.go index b630a84871c..915d986a4a2 100644 --- a/apps/advisor/pkg/app/checks/ifaces.go +++ b/apps/advisor/pkg/app/checks/ifaces.go @@ -24,6 +24,6 @@ type Step interface { Title() string // Description returns the description of the step Description() string - // Run executes the step and returns a list of errors - Run(ctx context.Context, obj *advisorv0alpha1.CheckSpec, items []any) ([]advisorv0alpha1.CheckReportError, error) + // Run executes the step for an item and returns a report + Run(ctx context.Context, obj *advisorv0alpha1.CheckSpec, item any) (*advisorv0alpha1.CheckReportError, error) } diff --git a/apps/advisor/pkg/app/checks/plugincheck/check.go b/apps/advisor/pkg/app/checks/plugincheck/check.go index c024b2608b0..f9dc853d0b8 100644 --- a/apps/advisor/pkg/app/checks/plugincheck/check.go +++ b/apps/advisor/pkg/app/checks/plugincheck/check.go @@ -78,35 +78,33 @@ func (s *deprecationStep) ID() string { return "deprecation" } -func (s *deprecationStep) Run(ctx context.Context, _ *advisor.CheckSpec, items []any) ([]advisor.CheckReportError, error) { - errs := []advisor.CheckReportError{} - for _, i := range items { - p, ok := i.(pluginstore.Plugin) - if !ok { - return nil, fmt.Errorf("invalid item type %T", i) - } - - // Skip if it's a core plugin - if p.IsCorePlugin() { - continue - } - - // Check if plugin is deprecated - i, err := s.PluginRepo.PluginInfo(ctx, p.ID) - if err != nil { - continue - } - if i.Status == "deprecated" { - errs = append(errs, checks.NewCheckReportError( - advisor.CheckReportErrorSeverityHigh, - fmt.Sprintf("Plugin deprecated: %s", p.ID), - "Check the documentation for recommended steps.", - s.ID(), - p.ID, - )) - } +func (s *deprecationStep) Run(ctx context.Context, _ *advisor.CheckSpec, it any) (*advisor.CheckReportError, error) { + p, ok := it.(pluginstore.Plugin) + if !ok { + return nil, fmt.Errorf("invalid item type %T", it) } - return errs, nil + + // Skip if it's a core plugin + if p.IsCorePlugin() { + return nil, nil + } + + // Check if plugin is deprecated + i, err := s.PluginRepo.PluginInfo(ctx, p.ID) + if err != nil { + // Unable to check deprecation status + return nil, nil + } + if i.Status == "deprecated" { + return checks.NewCheckReportError( + advisor.CheckReportErrorSeverityHigh, + fmt.Sprintf("Plugin deprecated: %s", p.ID), + "Check the documentation for recommended steps.", + s.ID(), + p.ID, + ), nil + } + return nil, nil } type updateStep struct { @@ -127,44 +125,42 @@ func (s *updateStep) ID() string { return "update" } -func (s *updateStep) Run(ctx context.Context, _ *advisor.CheckSpec, items []any) ([]advisor.CheckReportError, error) { - errs := []advisor.CheckReportError{} - for _, i := range items { - p, ok := i.(pluginstore.Plugin) - if !ok { - return nil, fmt.Errorf("invalid item type %T", i) - } - - // Skip if it's a core plugin - if p.IsCorePlugin() { - continue - } - - // Skip if it's managed or pinned - if s.isManaged(ctx, p.ID) || s.PluginPreinstall.IsPinned(p.ID) { - continue - } - - // Check if plugin has a newer version available - compatOpts := repo.NewCompatOpts(services.GrafanaVersion, sysruntime.GOOS, sysruntime.GOARCH) - info, err := s.PluginRepo.GetPluginArchiveInfo(ctx, p.ID, "", compatOpts) - if err != nil { - continue - } - if hasUpdate(p, info) { - errs = append(errs, checks.NewCheckReportError( - advisor.CheckReportErrorSeverityLow, - fmt.Sprintf("New version available for %s", p.ID), - fmt.Sprintf( - "Go to the plugin admin page"+ - " and upgrade to the latest version.", p.ID), - s.ID(), - p.ID, - )) - } +func (s *updateStep) Run(ctx context.Context, _ *advisor.CheckSpec, i any) (*advisor.CheckReportError, error) { + p, ok := i.(pluginstore.Plugin) + if !ok { + return nil, fmt.Errorf("invalid item type %T", i) } - return errs, nil + // Skip if it's a core plugin + if p.IsCorePlugin() { + return nil, nil + } + + // Skip if it's managed or pinned + if s.isManaged(ctx, p.ID) || s.PluginPreinstall.IsPinned(p.ID) { + return nil, nil + } + + // Check if plugin has a newer version available + compatOpts := repo.NewCompatOpts(services.GrafanaVersion, sysruntime.GOOS, sysruntime.GOARCH) + info, err := s.PluginRepo.GetPluginArchiveInfo(ctx, p.ID, "", compatOpts) + if err != nil { + // Unable to check updates + return nil, nil + } + if hasUpdate(p, info) { + return checks.NewCheckReportError( + advisor.CheckReportErrorSeverityLow, + fmt.Sprintf("New version available for %s", p.ID), + fmt.Sprintf( + "Go to the plugin admin page"+ + " and upgrade to the latest version.", p.ID), + s.ID(), + p.ID, + ), nil + } + + return nil, nil } func hasUpdate(current pluginstore.Plugin, latest *repo.PluginArchiveInfo) bool { diff --git a/apps/advisor/pkg/app/checks/plugincheck/check_test.go b/apps/advisor/pkg/app/checks/plugincheck/check_test.go index 29dc7ccaabd..ff5190278e2 100644 --- a/apps/advisor/pkg/app/checks/plugincheck/check_test.go +++ b/apps/advisor/pkg/app/checks/plugincheck/check_test.go @@ -136,9 +136,13 @@ func TestRun(t *testing.T) { assert.NoError(t, err) errs := []advisor.CheckReportError{} for _, step := range check.Steps() { - stepErrs, err := step.Run(context.Background(), &advisor.CheckSpec{}, items) - assert.NoError(t, err) - errs = append(errs, stepErrs...) + for _, item := range items { + stepErr, err := step.Run(context.Background(), &advisor.CheckSpec{}, item) + assert.NoError(t, err) + if stepErr != nil { + errs = append(errs, *stepErr) + } + } } assert.NoError(t, err) assert.Equal(t, len(tt.plugins), len(items)) diff --git a/apps/advisor/pkg/app/checks/utils.go b/apps/advisor/pkg/app/checks/utils.go index a6c4089da44..053dc591389 100644 --- a/apps/advisor/pkg/app/checks/utils.go +++ b/apps/advisor/pkg/app/checks/utils.go @@ -10,8 +10,8 @@ func NewCheckReportError( action string, stepID string, itemID string, -) advisor.CheckReportError { - return advisor.CheckReportError{ +) *advisor.CheckReportError { + return &advisor.CheckReportError{ Severity: severity, Reason: reason, Action: action, diff --git a/apps/advisor/pkg/app/utils.go b/apps/advisor/pkg/app/utils.go index c59dbaca21e..b1809dd2ee3 100644 --- a/apps/advisor/pkg/app/utils.go +++ b/apps/advisor/pkg/app/utils.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sync" claims "github.com/grafana/authlib/types" "github.com/grafana/grafana-app-sdk/resource" @@ -83,20 +84,17 @@ func processCheck(ctx context.Context, client resource.Client, obj resource.Obje } // Run the steps steps := check.Steps() - errs := []advisorv0alpha1.CheckReportError{} - for _, step := range steps { - stepErrs, err := step.Run(ctx, &c.Spec, items) - if err != nil { - setErr := setStatusAnnotation(ctx, client, obj, "error") - if setErr != nil { - return setErr - } - return fmt.Errorf("error running step %s: %w", step.Title(), err) + reportErrors, err := runStepsInParallel(ctx, &c.Spec, steps, items) + if err != nil { + setErr := setStatusAnnotation(ctx, client, obj, "error") + if setErr != nil { + return setErr } - errs = append(errs, stepErrs...) + return fmt.Errorf("error running steps: %w", err) } + report := &advisorv0alpha1.CheckV0alpha1StatusReport{ - Errors: errs, + Errors: reportErrors, Count: int64(len(items)), } err = setStatusAnnotation(ctx, client, obj, "processed") @@ -111,3 +109,35 @@ func processCheck(ctx context.Context, client resource.Client, obj resource.Obje }}, }, resource.PatchOptions{}, obj) } + +func runStepsInParallel(ctx context.Context, spec *advisorv0alpha1.CheckSpec, steps []checks.Step, items []any) ([]advisorv0alpha1.CheckReportError, error) { + reportErrs := []advisorv0alpha1.CheckReportError{} + var internalErr error + var wg sync.WaitGroup + var mu sync.Mutex + // Avoid too many concurrent requests + limit := make(chan struct{}, 10) + + for _, step := range steps { + for _, item := range items { + wg.Add(1) + limit <- struct{}{} + go func(step checks.Step, item any) { + defer wg.Done() + defer func() { <-limit }() + stepErr, err := step.Run(ctx, spec, item) + mu.Lock() + defer mu.Unlock() + if err != nil { + internalErr = fmt.Errorf("error running step %s: %w", step.ID(), err) + return + } + if stepErr != nil { + reportErrs = append(reportErrs, *stepErr) + } + }(step, item) + } + } + wg.Wait() + return reportErrs, internalErr +} diff --git a/apps/advisor/pkg/app/utils_test.go b/apps/advisor/pkg/app/utils_test.go index a13880f8209..5905e2a9c44 100644 --- a/apps/advisor/pkg/app/utils_test.go +++ b/apps/advisor/pkg/app/utils_test.go @@ -3,6 +3,7 @@ package app import ( "context" "errors" + "fmt" "testing" "github.com/grafana/grafana-app-sdk/resource" @@ -68,13 +69,45 @@ func TestProcessCheck(t *testing.T) { meta.SetCreatedBy("user:1") client := &mockClient{} ctx := context.TODO() - check := &mockCheck{} + check := &mockCheck{ + items: []any{"item"}, + } err = processCheck(ctx, client, obj, check) assert.NoError(t, err) assert.Equal(t, "processed", obj.GetAnnotations()[statusAnnotation]) } +func TestProcessMultipleCheckItems(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() + items := make([]any, 100) + for i := range items { + if i%2 == 0 { + items[i] = fmt.Sprintf("item-%d", i) + } else { + items[i] = errors.New("error") + } + } + check := &mockCheck{ + items: items, + } + + err = processCheck(ctx, client, obj, check) + assert.NoError(t, err) + assert.Equal(t, "processed", obj.GetAnnotations()[statusAnnotation]) + r := client.lastValue.(advisorv0alpha1.CheckV0alpha1StatusReport) + assert.Equal(t, r.Count, int64(100)) + assert.Len(t, r.Errors, 50) +} + func TestProcessCheck_AlreadyProcessed(t *testing.T) { obj := &advisorv0alpha1.Check{} obj.SetAnnotations(map[string]string{statusAnnotation: "processed"}) @@ -98,7 +131,8 @@ func TestProcessCheck_RunError(t *testing.T) { ctx := context.TODO() check := &mockCheck{ - err: errors.New("run error"), + items: []any{"item"}, + err: errors.New("run error"), } err = processCheck(ctx, client, obj, check) @@ -108,14 +142,17 @@ func TestProcessCheck_RunError(t *testing.T) { type mockClient struct { resource.Client + lastValue any } func (m *mockClient) PatchInto(ctx context.Context, id resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions, obj resource.Object) error { + m.lastValue = req.Operations[0].Value return nil } type mockCheck struct { - err error + err error + items []any } func (m *mockCheck) ID() string { @@ -123,7 +160,7 @@ func (m *mockCheck) ID() string { } func (m *mockCheck) Items(ctx context.Context) ([]any, error) { - return []any{}, nil + return m.items, nil } func (m *mockCheck) Steps() []checks.Step { @@ -136,10 +173,13 @@ type mockStep struct { err error } -func (m *mockStep) Run(ctx context.Context, obj *advisorv0alpha1.CheckSpec, items []any) ([]advisorv0alpha1.CheckReportError, error) { +func (m *mockStep) Run(ctx context.Context, obj *advisorv0alpha1.CheckSpec, items any) (*advisorv0alpha1.CheckReportError, error) { if m.err != nil { return nil, m.err } + if _, ok := items.(error); ok { + return &advisorv0alpha1.CheckReportError{}, nil + } return nil, nil } diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 13646ef8948..73143bc543b 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -233,6 +233,7 @@ Experimental features might be changed or removed without prior notice. | `elasticsearchImprovedParsing` | Enables less memory intensive Elasticsearch result parsing | | `datasourceConnectionsTab` | Shows defined connections for a data source in the plugins detail page | | `newLogsPanel` | Enables the new logs panel in Explore | +| `pluginsCDNSyncLoader` | Load plugins from CDN synchronously | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 3e0490dd97b..cbe09d02910 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -257,4 +257,5 @@ export interface FeatureToggles { alertingAlertmanagerExtraDedupStageStopPipeline?: boolean; newLogsPanel?: boolean; grafanaconThemes?: boolean; + pluginsCDNSyncLoader?: boolean; } diff --git a/packages/grafana-ui/src/components/ScrollContainer/ScrollContainer.tsx b/packages/grafana-ui/src/components/ScrollContainer/ScrollContainer.tsx index 42f2ae08683..d3336e20780 100644 --- a/packages/grafana-ui/src/components/ScrollContainer/ScrollContainer.tsx +++ b/packages/grafana-ui/src/components/ScrollContainer/ScrollContainer.tsx @@ -34,6 +34,7 @@ export const ScrollContainer = forwardRef = { maxHeight: '100%', minHeight: 0, + minWidth: 0, }; const boxProps = { ...defaults, ...rest }; diff --git a/pkg/expr/sql/db_test.go b/pkg/expr/sql/db_test.go index 258507121c2..e648407e41a 100644 --- a/pkg/expr/sql/db_test.go +++ b/pkg/expr/sql/db_test.go @@ -148,27 +148,21 @@ func TestQueryFramesNumericSelect(t *testing.T) { } func TestQueryFramesDateTimeSelect(t *testing.T) { - t.Skip("need a fix in go-mysql-server, and then handle the datetime strings (or figure out why strings and not time.Time)") expectedFrame := &data.Frame{ RefID: "a", Name: "a", Fields: []*data.Field{ - data.NewField("ts", nil, []time.Time{}), + data.NewField("ts", nil, []*time.Time{ + p(time.Date(2025, 2, 3, 3, 0, 0, 0, time.UTC)), + }), }, } db := DB{} - // It doesn't like the T in the time string qry := `SELECT str_to_date('2025-02-03T03:00:00','%Y-%m-%dT%H:%i:%s') as ts` - // This comes back as a string, which needs to be dealt with? - //qry := `SELECT str_to_date('2025-02-03-03:00:00','%Y-%m-%d-%H:%i:%s') as ts` - - // This is a datetime(6), need to deal with that as well - //qry := `SELECT current_timestamp() as ts` - - f, err := db.QueryFrames(context.Background(), "b", qry, []*data.Frame{}) + f, err := db.QueryFrames(context.Background(), "a", qry, nil) require.NoError(t, err) if diff := cmp.Diff(expectedFrame, f, data.FrameTestCompareOptions()...); diff != "" { diff --git a/pkg/expr/sql/parser_allow.go b/pkg/expr/sql/parser_allow.go index 8a2ad436920..3b5d553af08 100644 --- a/pkg/expr/sql/parser_allow.go +++ b/pkg/expr/sql/parser_allow.go @@ -54,7 +54,13 @@ func allowedNode(node sqlparser.SQLNode) (b bool) { case *sqlparser.AliasedExpr, *sqlparser.AliasedTableExpr: return - case *sqlparser.BinaryExpr: + case *sqlparser.AndExpr, *sqlparser.OrExpr: + return + + case *sqlparser.BinaryExpr, *sqlparser.UnaryExpr: + return + + case sqlparser.BoolVal: return case sqlparser.ColIdent, *sqlparser.ColName, sqlparser.Columns: @@ -87,6 +93,9 @@ func allowedNode(node sqlparser.SQLNode) (b bool) { case *sqlparser.Select, sqlparser.SelectExprs: return + case *sqlparser.SetOp: + return + case *sqlparser.StarExpr: return @@ -102,6 +111,9 @@ func allowedNode(node sqlparser.SQLNode) (b bool) { case *sqlparser.Over: return + case *sqlparser.ParenExpr: + return + case *sqlparser.Subquery: return @@ -124,12 +136,18 @@ func allowedFunction(f *sqlparser.FuncExpr) (b bool) { b = true // so don't have to return true in every case but default switch strings.ToLower(f.Name.String()) { + case "if": + return + case "sum", "avg", "count", "min", "max": return case "coalesce": return + case "str_to_date": + return + default: return false } diff --git a/pkg/expr/sql/parser_allow_test.go b/pkg/expr/sql/parser_allow_test.go index aeeb1a40c69..98dd3277173 100644 --- a/pkg/expr/sql/parser_allow_test.go +++ b/pkg/expr/sql/parser_allow_test.go @@ -17,6 +17,11 @@ func TestAllowQuery(t *testing.T) { q: example_metrics_query, err: nil, }, + { + name: "an example from todd", + q: example_argo_commit_example, + err: nil, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { @@ -78,3 +83,29 @@ SELECT * FROM usage_by_team CROSS JOIN total_metrics CROSS JOIN total_traces` + +var example_argo_commit_example = `WITH +gh AS + (SELECT Count(*) AS commits + FROM + (SELECT * + FROM oss_repo + UNION ALL SELECT * + FROM ent_repo) AS ent_repos), +argo_success AS + (SELECT IF(argo.status = 'Succeeded', argo.value, 0) AS value FROM argo), +argo_failure AS + (SELECT IF(argo.status = 'Failed', argo.value, 0) AS value FROM argo) +SELECT IF(env.value > 1, TRUE, workflows.runs < 1 OR gh.commits < 1) AS status, + gh.commits AS 'merged commits to main (OSS + enterprise)', + drone.value AS 'enterprise downstream publish', + workflows.runs AS 'github trigger instant workflow runs today', + argo_success.value AS 'argo success', + argo_failure.value AS 'argo failure', + (env.value - 1) AS 'new dev instant deployments' +FROM drone, + env, + gh, + argo_success, + argo_failure, + workflows;` diff --git a/pkg/plugins/config/config.go b/pkg/plugins/config/config.go index a26c66176a1..67b5b9c15ee 100644 --- a/pkg/plugins/config/config.go +++ b/pkg/plugins/config/config.go @@ -31,9 +31,10 @@ type PluginManagementCfg struct { // Features contains the feature toggles used for the plugin management system. type Features struct { - ExternalCorePluginsEnabled bool - SkipHostEnvVarsEnabled bool - SriChecksEnabled bool + ExternalCorePluginsEnabled bool + SkipHostEnvVarsEnabled bool + SriChecksEnabled bool + PluginsCDNSyncLoaderEnabled bool } // NewPluginManagementCfg returns a new PluginManagementCfg. diff --git a/pkg/plugins/manager/loader/loader.go b/pkg/plugins/manager/loader/loader.go index 2abc88ef735..f3c0d47e73b 100644 --- a/pkg/plugins/manager/loader/loader.go +++ b/pkg/plugins/manager/loader/loader.go @@ -8,6 +8,7 @@ import ( "time" "github.com/grafana/grafana/pkg/plugins" + pluginsCfg "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/bootstrap" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/discovery" @@ -17,7 +18,10 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" ) +const concurrencyLimit = 32 + type Loader struct { + cfg *pluginsCfg.PluginManagementCfg discovery discovery.Discoverer bootstrap bootstrap.Bootstrapper initializer initialization.Initializer @@ -27,9 +31,13 @@ type Loader struct { log log.Logger } -func New(discovery discovery.Discoverer, bootstrap bootstrap.Bootstrapper, validation validation.Validator, - initializer initialization.Initializer, termination termination.Terminator, errorTracker pluginerrs.ErrorTracker) *Loader { +func New( + cfg *pluginsCfg.PluginManagementCfg, + discovery discovery.Discoverer, bootstrap bootstrap.Bootstrapper, validation validation.Validator, + initializer initialization.Initializer, termination termination.Terminator, errorTracker pluginerrs.ErrorTracker, +) *Loader { return &Loader{ + cfg: cfg, discovery: discovery, bootstrap: bootstrap, validation: validation, @@ -55,11 +63,14 @@ func (l *Loader) recordError(ctx context.Context, p *plugins.Plugin, err error) func (l *Loader) Load(ctx context.Context, src plugins.PluginSource) ([]*plugins.Plugin, error) { end := l.instrumentLoad(ctx, src) + st := time.Now() discoveredPlugins, err := l.discovery.Discover(ctx, src) if err != nil { return nil, err } + l.log.Debug("Discovered", "class", src.PluginClass(ctx), "duration", time.Since(st)) + st = time.Now() bootstrappedPlugins := []*plugins.Plugin{} for _, foundBundle := range discoveredPlugins { bootstrappedPlugin, err := l.bootstrap.Bootstrap(ctx, src, foundBundle) @@ -72,17 +83,47 @@ func (l *Loader) Load(ctx context.Context, src plugins.PluginSource) ([]*plugins } bootstrappedPlugins = append(bootstrappedPlugins, bootstrappedPlugin...) } + l.log.Debug("Bootstrapped", "class", src.PluginClass(ctx), "duration", time.Since(st)) + st = time.Now() validatedPlugins := []*plugins.Plugin{} + type validateResult struct { + bootstrappedPlugin *plugins.Plugin + err error + } + validateResults := make(chan validateResult, len(bootstrappedPlugins)) + + // If the PluginsCDNSyncLoaderEnabled feature is enabled, validate plugins in parallel. + // Otherwise, validate plugins sequentially. + var limitSize int + if l.cfg.Features.PluginsCDNSyncLoaderEnabled && src.PluginClass(ctx) == plugins.ClassCDN { + limitSize = min(len(bootstrappedPlugins), concurrencyLimit) + } else { + limitSize = 1 + } + limit := make(chan struct{}, limitSize) for _, bootstrappedPlugin := range bootstrappedPlugins { - err := l.validation.Validate(ctx, bootstrappedPlugin) - if err != nil { - l.recordError(ctx, bootstrappedPlugin, err) + limit <- struct{}{} + go func(p *plugins.Plugin) { + err := l.validation.Validate(ctx, p) + validateResults <- validateResult{ + bootstrappedPlugin: bootstrappedPlugin, + err: err, + } + <-limit + }(bootstrappedPlugin) + } + for i := 0; i < len(bootstrappedPlugins); i++ { + r := <-validateResults + if r.err != nil { + l.recordError(ctx, r.bootstrappedPlugin, r.err) continue } - validatedPlugins = append(validatedPlugins, bootstrappedPlugin) + validatedPlugins = append(validatedPlugins, r.bootstrappedPlugin) } + l.log.Debug("Validated", "class", src.PluginClass(ctx), "duration", time.Since(st), "total", len(validatedPlugins)) + st = time.Now() initializedPlugins := []*plugins.Plugin{} for _, validatedPlugin := range validatedPlugins { initializedPlugin, err := l.initializer.Initialize(ctx, validatedPlugin) @@ -92,6 +133,7 @@ func (l *Loader) Load(ctx context.Context, src plugins.PluginSource) ([]*plugins } initializedPlugins = append(initializedPlugins, initializedPlugin) } + l.log.Debug("Initialized", "class", src.PluginClass(ctx), "duration", time.Since(st)) // Clean errors from registry for initialized plugins for _, p := range initializedPlugins { diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index 3472727f127..d39c9c21303 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -58,6 +58,7 @@ func TestLoader_Load(t *testing.T) { t.Errorf("could not construct absolute path of current dir") return } + zeroCfg := &config.PluginManagementCfg{} tests := []struct { name string class plugins.Class @@ -420,7 +421,7 @@ func TestLoader_Load(t *testing.T) { require.NoError(t, err) et := pluginerrs.ProvideErrorTracker() - l := New(discovery.New(tt.cfg, discovery.Opts{}), bootstrap.New(tt.cfg, bootstrap.Opts{}), + l := New(zeroCfg, discovery.New(tt.cfg, discovery.Opts{}), bootstrap.New(tt.cfg, bootstrap.Opts{}), validation.New(tt.cfg, validation.Opts{}), initialization.New(tt.cfg, initialization.Opts{}), terminationStage, et) @@ -455,6 +456,7 @@ func TestLoader_Load(t *testing.T) { var steps []string l := New( + zeroCfg, &fakes.FakeDiscoverer{ DiscoverFunc: func(ctx context.Context, s plugins.PluginSource) ([]*plugins.FoundBundle, error) { require.Equal(t, src, s) @@ -512,6 +514,7 @@ func TestLoader_Load(t *testing.T) { var steps []string l := New( + zeroCfg, &fakes.FakeDiscoverer{ DiscoverFunc: func(ctx context.Context, s plugins.PluginSource) ([]*plugins.FoundBundle, error) { require.Equal(t, src, s) @@ -574,6 +577,7 @@ func TestLoader_Load(t *testing.T) { var steps []string l := New( + zeroCfg, &fakes.FakeDiscoverer{ DiscoverFunc: func(ctx context.Context, s plugins.PluginSource) ([]*plugins.FoundBundle, error) { require.Equal(t, src, s) @@ -629,7 +633,9 @@ func TestLoader_Unload(t *testing.T) { } for _, tc := range tcs { - l := New(&fakes.FakeDiscoverer{}, + l := New( + &config.PluginManagementCfg{}, + &fakes.FakeDiscoverer{}, &fakes.FakeBootstrapper{}, &fakes.FakeValidator{}, &fakes.FakeInitializer{}, diff --git a/pkg/plugins/manager/pipeline/validation/steps.go b/pkg/plugins/manager/pipeline/validation/steps.go index 10af0cce5e4..1b41c1b5d6d 100644 --- a/pkg/plugins/manager/pipeline/validation/steps.go +++ b/pkg/plugins/manager/pipeline/validation/steps.go @@ -57,6 +57,11 @@ func newModuleJSValidator() *ModuleJSValidator { } func (v *ModuleJSValidator) Validate(_ context.Context, p *plugins.Plugin) error { + // CDN plugins are ignored because the module.js is guaranteed to exist + if p.Class == plugins.ClassCDN { + return nil + } + if !p.IsRenderer() && !p.IsCorePlugin() { f, err := p.FS.Open("module.js") if err != nil { diff --git a/pkg/registry/apis/dashboard/authorizer.go b/pkg/registry/apis/dashboard/authorizer.go index 1281349a6f2..0b51cd32c68 100644 --- a/pkg/registry/apis/dashboard/authorizer.go +++ b/pkg/registry/apis/dashboard/authorizer.go @@ -16,7 +16,7 @@ func GetAuthorizer(dashboardService dashboards.DashboardService, l log.Logger) a return authorizer.AuthorizerFunc( func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { // Use the standard authorizer - if !attr.IsResourceRequest() || attr.GetResource() == "search" { + if !attr.IsResourceRequest() { return authorizer.DecisionNoOpinion, "", nil } @@ -25,7 +25,8 @@ func GetAuthorizer(dashboardService dashboards.DashboardService, l log.Logger) a return authorizer.DecisionDeny, "", err } - if attr.GetName() == "" { + // Allow search and list requests + if attr.GetResource() == "search" || attr.GetName() == "" { return authorizer.DecisionNoOpinion, "", nil } diff --git a/pkg/registry/apis/dashboard/large.go b/pkg/registry/apis/dashboard/large.go index 9530b247d2f..e54b4d252cb 100644 --- a/pkg/registry/apis/dashboard/large.go +++ b/pkg/registry/apis/dashboard/large.go @@ -51,7 +51,16 @@ func NewDashboardLargeObjectSupport(scheme *runtime.Scheme) *apistore.BasicLarge if err != nil { return err } - return json.Unmarshal(blob, &dash.Spec) + + if err := json.Unmarshal(blob, &dash.Spec); err != nil { + return fmt.Errorf("failed to unmarshal blob into spec: %w", err) + } + + if err := scheme.Convert(dash, obj, nil); err != nil { + return fmt.Errorf("failed to update original object: %w", err) + } + + return nil }, } } diff --git a/pkg/registry/apis/dashboard/large_test.go b/pkg/registry/apis/dashboard/large_test.go index 45510304a65..c5d65fa8f5f 100644 --- a/pkg/registry/apis/dashboard/large_test.go +++ b/pkg/registry/apis/dashboard/large_test.go @@ -59,11 +59,17 @@ func TestLargeDashboardSupport(t *testing.T) { }`, string(small)) // Now make it big again - err = largeObject.RebuildSpec(dash, f) + rehydratedDash := &dashboardv0alpha1.Dashboard{ + ObjectMeta: v1.ObjectMeta{ + Name: "test", + Namespace: "test", + }, + } + err = largeObject.RebuildSpec(rehydratedDash, f) require.NoError(t, err) // check that all panels exist again - panels, found, err = unstructured.NestedSlice(dash.Spec.Object, "panels") + panels, found, err = unstructured.NestedSlice(rehydratedDash.Spec.Object, "panels") require.NoError(t, err) require.True(t, found) require.Len(t, panels, expectedPanelCount) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index ac2eaffc940..81dda206467 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1794,6 +1794,12 @@ var ( HideFromDocs: true, RequiresRestart: true, }, + { + Name: "pluginsCDNSyncLoader", + Description: "Load plugins from CDN synchronously", + Stage: FeatureStageExperimental, + Owner: grafanaPluginsPlatformSquad, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index e88c9c3d176..d21b4c4fded 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -238,3 +238,4 @@ alertingAlertmanagerExtraDedupStage,experimental,@grafana/alerting-squad,false,t alertingAlertmanagerExtraDedupStageStopPipeline,experimental,@grafana/alerting-squad,false,true,false newLogsPanel,experimental,@grafana/observability-logs,false,false,true grafanaconThemes,experimental,@grafana/grafana-frontend-platform,false,true,false +pluginsCDNSyncLoader,experimental,@grafana/plugins-platform-backend,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index e910ed15857..bedb497d4f5 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -962,4 +962,8 @@ const ( // FlagGrafanaconThemes // Enables the temporary themes for GrafanaCon FlagGrafanaconThemes = "grafanaconThemes" + + // FlagPluginsCDNSyncLoader + // Load plugins from CDN synchronously + FlagPluginsCDNSyncLoader = "pluginsCDNSyncLoader" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 607d0581270..db65ff4fdb8 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3034,6 +3034,18 @@ "frontend": true } }, + { + "metadata": { + "name": "pluginsCDNSyncLoader", + "resourceVersion": "1737026684018", + "creationTimestamp": "2025-01-16T11:24:44Z" + }, + "spec": { + "description": "Load plugins from CDN synchronously", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend" + } + }, { "metadata": { "name": "pluginsDetailsRightPanel", diff --git a/pkg/services/pluginsintegration/loader/loader.go b/pkg/services/pluginsintegration/loader/loader.go index 4852e84d1b4..b3a03885752 100644 --- a/pkg/services/pluginsintegration/loader/loader.go +++ b/pkg/services/pluginsintegration/loader/loader.go @@ -4,6 +4,7 @@ import ( "context" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/config" pluginsLoader "github.com/grafana/grafana/pkg/plugins/manager/loader" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/bootstrap" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/discovery" @@ -19,11 +20,13 @@ type Loader struct { loader *pluginsLoader.Loader } -func ProvideService(discovery discovery.Discoverer, bootstrap bootstrap.Bootstrapper, validation validation.Validator, +func ProvideService( + cfg *config.PluginManagementCfg, + discovery discovery.Discoverer, bootstrap bootstrap.Bootstrapper, validation validation.Validator, initializer initialization.Initializer, termination termination.Terminator, errorTracker pluginerrs.ErrorTracker, ) *Loader { return &Loader{ - loader: pluginsLoader.New(discovery, bootstrap, validation, initializer, termination, errorTracker), + loader: pluginsLoader.New(cfg, discovery, bootstrap, validation, initializer, termination, errorTracker), } } diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go index 06466240b9e..ca6fef68b50 100644 --- a/pkg/services/pluginsintegration/loader/loader_test.go +++ b/pkg/services/pluginsintegration/loader/loader_test.go @@ -1575,7 +1575,7 @@ func newLoader(t *testing.T, cfg *config.PluginManagementCfg, reg registry.Servi terminate, err := pipeline.ProvideTerminationStage(cfg, reg, proc) require.NoError(t, err) - return ProvideService(pipeline.ProvideDiscoveryStage(cfg, + return ProvideService(cfg, pipeline.ProvideDiscoveryStage(cfg, finder.NewLocalFinder(false), reg), pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), assets), pipeline.ProvideValidationStage(cfg, signature.NewValidator(signature.NewUnsignedAuthorizer(cfg)), angularInspector), @@ -1607,7 +1607,7 @@ func newLoaderWithOpts(t *testing.T, cfg *config.PluginManagementCfg, opts loade backendFactoryProvider = fakes.NewFakeBackendProcessProvider() } - return ProvideService(pipeline.ProvideDiscoveryStage(cfg, + return ProvideService(cfg, pipeline.ProvideDiscoveryStage(cfg, finder.NewLocalFinder(false), reg), pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), assets), pipeline.ProvideValidationStage(cfg, signature.NewValidator(signature.NewUnsignedAuthorizer(cfg)), angularInspector), diff --git a/pkg/services/pluginsintegration/pluginconfig/config.go b/pkg/services/pluginsintegration/pluginconfig/config.go index 71952c64eb8..47751b07711 100644 --- a/pkg/services/pluginsintegration/pluginconfig/config.go +++ b/pkg/services/pluginsintegration/pluginconfig/config.go @@ -30,9 +30,10 @@ func ProvidePluginManagementConfig(cfg *setting.Cfg, settingProvider setting.Pro cfg.PluginsCDNURLTemplate, cfg.AppURL, config.Features{ - ExternalCorePluginsEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalCorePlugins), - SkipHostEnvVarsEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSkipHostEnvVars), - SriChecksEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSriChecks), + ExternalCorePluginsEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalCorePlugins), + SkipHostEnvVarsEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSkipHostEnvVars), + SriChecksEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSriChecks), + PluginsCDNSyncLoaderEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsCDNSyncLoader), }, cfg.AngularSupportEnabled, cfg.GrafanaComAPIURL, diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go index 22929782cf8..eca6c3be53f 100644 --- a/pkg/services/pluginsintegration/pluginsintegration.go +++ b/pkg/services/pluginsintegration/pluginsintegration.go @@ -107,8 +107,6 @@ var WireSet = wire.NewSet( wire.Bind(new(repo.Service), new(*repo.Manager)), licensing.ProvideLicensing, wire.Bind(new(plugins.Licensing), new(*licensing.Service)), - wire.Bind(new(sources.Registry), new(*sources.Service)), - sources.ProvideService, pluginSettings.ProvideService, wire.Bind(new(pluginsettings.Service), new(*pluginSettings.Service)), filestore.ProvideService, @@ -146,6 +144,8 @@ var WireExtensionSet = wire.NewSet( wire.Bind(new(plugins.Client), new(*backend.MiddlewareHandler)), managedplugins.NewNoop, wire.Bind(new(managedplugins.Manager), new(*managedplugins.Noop)), + sources.ProvideService, + wire.Bind(new(sources.Registry), new(*sources.Service)), ) func ProvideClientWithMiddlewares( diff --git a/pkg/services/pluginsintegration/renderer/renderer.go b/pkg/services/pluginsintegration/renderer/renderer.go index ff8003b957c..5623b5812f8 100644 --- a/pkg/services/pluginsintegration/renderer/renderer.go +++ b/pkg/services/pluginsintegration/renderer/renderer.go @@ -141,5 +141,5 @@ func createLoader(cfg *config.PluginManagementCfg, pluginEnvProvider envvars.Pro et := pluginerrs.ProvideErrorTracker() - return loader.New(d, b, v, i, t, et), nil + return loader.New(cfg, d, b, v, i, t, et), nil } diff --git a/pkg/services/pluginsintegration/test_helper.go b/pkg/services/pluginsintegration/test_helper.go index d74e18a4bcf..92ce0dab164 100644 --- a/pkg/services/pluginsintegration/test_helper.go +++ b/pkg/services/pluginsintegration/test_helper.go @@ -110,5 +110,5 @@ func CreateTestLoader(t *testing.T, cfg *pluginsCfg.PluginManagementCfg, opts Lo require.NoError(t, err) } - return loader.New(opts.Discoverer, opts.Bootstrapper, opts.Validator, opts.Initializer, opts.Terminator, pluginerrs.ProvideErrorTracker()) + return loader.New(cfg, opts.Discoverer, opts.Bootstrapper, opts.Validator, opts.Initializer, opts.Terminator, pluginerrs.ProvideErrorTracker()) } diff --git a/pkg/tests/apis/provisioning/provisioning_test.go b/pkg/tests/apis/provisioning/provisioning_test.go index fcc06bdc8c4..0605540d948 100644 --- a/pkg/tests/apis/provisioning/provisioning_test.go +++ b/pkg/tests/apis/provisioning/provisioning_test.go @@ -5,27 +5,29 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" - "math/rand" "os" "path" "strings" "testing" "time" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - dashboard "github.com/grafana/grafana/pkg/apis/dashboard/v1alpha1" + folder "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "golang.org/x/exp/rand" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" ) func TestMain(m *testing.M) { @@ -43,50 +45,52 @@ func TestIntegrationProvisioning(t *testing.T) { AppModeProduction: false, // required for experimental APIs EnableFeatureToggles: []string{ featuremgmt.FlagProvisioning, - featuremgmt.FlagKubernetesFoldersServiceV2, // Required for tests that deal with folders. + featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, + featuremgmt.FlagKubernetesCliDashboards, + featuremgmt.FlagKubernetesFoldersServiceV2, featuremgmt.FlagUnifiedStorageSearch, }, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": { + DualWriterMode: rest.Mode5, + }, + "folders.folder.grafana.app": { + DualWriterMode: rest.Mode5, + }, + }, PermittedProvisioningPaths: ".|" + provisioningPath, }) - // Scope create+get client := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Admin, Namespace: "default", // actually org1 - GVR: schema.GroupVersionResource{ - Group: "provisioning.grafana.app", Version: "v0alpha1", Resource: "repositories", - }, + GVR: provisioning.RepositoryResourceInfo.GroupVersionResource(), }) jobClient := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Admin, Namespace: "default", // actually org1 - GVR: schema.GroupVersionResource{ - Group: "provisioning.grafana.app", Version: "v0alpha1", Resource: "jobs", - }, - }) - - // Repo client, but less guard rails. - restClient := helper.Org1.Admin.RESTClient(t, &schema.GroupVersion{ - Group: "provisioning.grafana.app", Version: "v0alpha1", + GVR: provisioning.JobResourceInfo.GroupVersionResource(), }) folderClient := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Admin, Namespace: "default", // actually org1 - GVR: schema.GroupVersionResource{ - Group: "folder.grafana.app", Version: "v0alpha1", Resource: "folders", - }, + GVR: folder.FolderResourceInfo.GroupVersionResource(), }) dashboardClient := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Admin, Namespace: "default", // actually org1 - GVR: schema.GroupVersionResource{ - Group: dashboard.GROUP, Version: dashboard.VERSION, Resource: "dashboards", - }, + GVR: dashboard.DashboardResourceInfo.GroupVersionResource(), }) + // Repo client, but less guard rails. Useful for subresources. We'll need this later... + restClient := helper.Org1.Admin.RESTClient(t, &schema.GroupVersion{ + Group: "provisioning.grafana.app", Version: "v0alpha1", + }) + _ = restClient + cleanSlate := func(t *testing.T) { helper.GetEnv().GitHubMockFactory.Constructor = func(ttc github.TestingTWithCleanup) github.Client { client := github.NewMockClient(ttc) @@ -120,127 +124,42 @@ func TestIntegrationProvisioning(t *testing.T) { } cleanSlate(t) - t.Run("Check basic create and get", func(t *testing.T) { + t.Run("Creating and getting repositories", func(t *testing.T) { cleanSlate(t) createOptions := metav1.CreateOptions{FieldValidation: "Strict"} - // Load the samples - _, err := client.Resource.Create(ctx, - helper.LoadYAMLOrJSONFile("testdata/local-conf-provisioning-sample.yaml"), - createOptions, - ) - require.NoError(t, err) + for _, inputFilePath := range []string{ + "testdata/github-example.json", + "testdata/local-conf-provisioning-sample.json", + "testdata/local-devenv.json", + "testdata/local-tmp.json", + "testdata/local-xxx.json", + "testdata/s3-example.json", + } { + t.Run(inputFilePath, func(t *testing.T) { + input := helper.LoadYAMLOrJSONFile(inputFilePath) + expectedOutput, err := json.MarshalIndent(input.Object["spec"], "", " ") + require.NoError(t, err, "failed to marshal JSON from input spec") - _, err = client.Resource.Create(ctx, - helper.LoadYAMLOrJSONFile("testdata/local-devenv.yaml"), - createOptions, - ) - require.NoError(t, err) + _, err = client.Resource.Create(ctx, input, createOptions) + require.NoError(t, err, "failed to create resource") - _, err = client.Resource.Create(ctx, - helper.LoadYAMLOrJSONFile("testdata/github-example.yaml"), - createOptions, - ) - require.NoError(t, err) + output, err := client.Resource.Get(ctx, mustNestedString(input.Object, "metadata", "name"), metav1.GetOptions{}) + require.NoError(t, err, "failed to read back resource") + outputJSON, err := json.MarshalIndent(output.Object["spec"], "", " ") + require.NoError(t, err, "failed to marshal JSON from read back resource") - _, err = client.Resource.Create(ctx, - helper.LoadYAMLOrJSONFile("testdata/s3-example.yaml"), - createOptions, - ) - require.NoError(t, err) - - samples, err := client.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - found := map[string]any{} - for _, v := range samples.Items { - found[v.GetName()] = v.Object["spec"] + require.JSONEq(t, string(expectedOutput), string(outputJSON)) + }) } - - js, _ := json.MarshalIndent(found, "", " ") - require.JSONEq(t, `{ - "github-example": { - "description": "load resources from github", - "editing": { - "create": true, - "delete": true, - "update": true - }, - "github": { - "branch": "dummy-branch", - "branchWorkflow": true, - "generateDashboardPreviews": true, - "owner": "grafana", - "repository": "git-ui-sync-demo", - "token": "github_pat_dummy" - }, - "sync": { - "enabled": true, - "target": "mirror" - }, - "title": "Github Example", - "type": "github" - }, - "local-conf-provisioning-sample": { - "description": "load resources from https://github.com/grafana/grafana/tree/main/conf/provisioning/sample", - "editing": { - "create": true, - "delete": true, - "update": true - }, - "local": { - "path": "provisioning/sample" - }, - "sync": { - "enabled": false, - "target": "" - }, - "title": "Config provisioning files", - "type": "local" - }, - "local-devenv": { - "description": "load https://github.com/grafana/grafana/tree/main/devenv/dev-dashboards", - "editing": { - "create": true, - "delete": true, - "update": true - }, - "local": { - "path": "devenv/dev-dashboards" - }, - "sync": { - "enabled": false, - "target": "mirror" - }, - "title": "Load devenv dashboards", - "type": "local" - }, - "s3-example": { - "description": "load resources from an S3 bucket", - "editing": { - "create": false, - "delete": false, - "update": false - }, - "s3": { - "bucket": "my-bucket", - "region": "us-west-1" - }, - "sync": { - "enabled": false, - "target": "mirror" - }, - "title": "S3 Example", - "type": "s3" - } - }`, string(js)) }) t.Run("creating repository creates folder", func(t *testing.T) { cleanSlate(t) _, err := client.Resource.Update(ctx, - helper.LoadYAMLOrJSONFile("testdata/github-example.yaml"), + helper.LoadYAMLOrJSONFile("testdata/github-example.json"), metav1.UpdateOptions{}, ) require.NoError(t, err) @@ -296,7 +215,7 @@ func TestIntegrationProvisioning(t *testing.T) { } _, err := client.Resource.Update(ctx, - helper.LoadYAMLOrJSONFile("testdata/github-example.yaml"), + helper.LoadYAMLOrJSONFile("testdata/github-example.json"), metav1.UpdateOptions{}, ) require.NoError(t, err) @@ -341,7 +260,7 @@ func TestIntegrationProvisioning(t *testing.T) { cleanSlate(t) _, err := client.Resource.Update(ctx, - helper.LoadYAMLOrJSONFile("testdata/local-devenv.yaml"), + helper.LoadYAMLOrJSONFile("testdata/local-devenv.json"), metav1.UpdateOptions{}, ) require.NoError(t, err) @@ -380,7 +299,7 @@ func TestIntegrationProvisioning(t *testing.T) { repoPath := path.Join(provisioningPath, repo, randomAsciiStr(10)) err := os.MkdirAll(repoPath, 0700) require.NoError(t, err, "should be able to create repo path") - localTmp := helper.LoadYAMLOrJSONFile("testdata/local-tmp.yaml") + localTmp := helper.LoadYAMLOrJSONFile("testdata/local-tmp.json") require.NoError(t, unstructured.SetNestedField(localTmp.Object, repoPath, "spec", "local", "path")) _, err = client.Resource.Create(ctx, localTmp, metav1.CreateOptions{}) diff --git a/pkg/tests/apis/provisioning/testdata/github-example.json b/pkg/tests/apis/provisioning/testdata/github-example.json new file mode 100644 index 00000000000..78165a87324 --- /dev/null +++ b/pkg/tests/apis/provisioning/testdata/github-example.json @@ -0,0 +1,26 @@ +{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": { + "name": "github-example" + }, + "spec": { + "title": "Github Example", + "description": "load resources from github", + "type": "github", + "readOnly": false, + "sync": { + "enabled": true, + "target": "mirror", + "intervalSeconds": 60 + }, + "github": { + "owner": "grafana", + "repository": "git-ui-sync-demo", + "branch": "dummy-branch", + "branchWorkflow": true, + "generateDashboardPreviews": true, + "token": "github_pat_dummy" + } + } +} \ No newline at end of file diff --git a/pkg/tests/apis/provisioning/testdata/github-example.yaml b/pkg/tests/apis/provisioning/testdata/github-example.yaml deleted file mode 100644 index 289c030aa87..00000000000 --- a/pkg/tests/apis/provisioning/testdata/github-example.yaml +++ /dev/null @@ -1,22 +0,0 @@ -apiVersion: provisioning.grafana.app/v0alpha1 -kind: Repository -metadata: - name: github-example -spec: - title: Github Example - description: load resources from github - type: github - sync: - enabled: true - target: mirror - editing: - create: true - update: true - delete: true - github: - owner: grafana - repository: git-ui-sync-demo - branch: dummy-branch - branchWorkflow: true - generateDashboardPreviews: true - token: "github_pat_dummy" diff --git a/pkg/tests/apis/provisioning/testdata/local-conf-provisioning-sample.json b/pkg/tests/apis/provisioning/testdata/local-conf-provisioning-sample.json new file mode 100644 index 00000000000..419d1701eb3 --- /dev/null +++ b/pkg/tests/apis/provisioning/testdata/local-conf-provisioning-sample.json @@ -0,0 +1,21 @@ +{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": { + "name": "local-conf-provisioning-sample" + }, + "spec": { + "description": "load resources from https://github.com/grafana/grafana/tree/main/conf/provisioning/sample", + "local": { + "path": "provisioning/sample" + }, + "title": "Config provisioning files", + "type": "local", + "readOnly": false, + "sync": { + "enabled": false, + "target": "", + "intervalSeconds": 60 + } + } +} \ No newline at end of file diff --git a/pkg/tests/apis/provisioning/testdata/local-conf-provisioning-sample.yaml b/pkg/tests/apis/provisioning/testdata/local-conf-provisioning-sample.yaml deleted file mode 100644 index 3fab6889fad..00000000000 --- a/pkg/tests/apis/provisioning/testdata/local-conf-provisioning-sample.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: provisioning.grafana.app/v0alpha1 -kind: Repository -metadata: - name: local-conf-provisioning-sample -spec: - title: Config provisioning files - description: load resources from https://github.com/grafana/grafana/tree/main/conf/provisioning/sample - type: local - editing: - create: true - update: true - delete: true - local: - path: provisioning/sample diff --git a/pkg/tests/apis/provisioning/testdata/local-devenv.json b/pkg/tests/apis/provisioning/testdata/local-devenv.json new file mode 100644 index 00000000000..a92647df91c --- /dev/null +++ b/pkg/tests/apis/provisioning/testdata/local-devenv.json @@ -0,0 +1,21 @@ +{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": { + "name": "local-devenv" + }, + "spec": { + "title": "Load devenv dashboards", + "description": "Load /devenv/dev-dashboards (from root of repository)", + "sync": { + "enabled": false, + "target": "mirror", + "intervalSeconds": 60 + }, + "readOnly": false, + "type": "local", + "local": { + "path": "devenv/dev-dashboards" + } + } +} \ No newline at end of file diff --git a/pkg/tests/apis/provisioning/testdata/local-devenv.yaml b/pkg/tests/apis/provisioning/testdata/local-devenv.yaml deleted file mode 100644 index d07525fdc73..00000000000 --- a/pkg/tests/apis/provisioning/testdata/local-devenv.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: provisioning.grafana.app/v0alpha1 -kind: Repository -metadata: - name: local-devenv -spec: - title: Load devenv dashboards - description: load https://github.com/grafana/grafana/tree/main/devenv/dev-dashboards - sync: - enabled: false - target: mirror - editing: - create: true - update: true - delete: true - type: local - local: - path: devenv/dev-dashboards diff --git a/pkg/tests/apis/provisioning/testdata/local-tmp.json b/pkg/tests/apis/provisioning/testdata/local-tmp.json new file mode 100644 index 00000000000..37532261eee --- /dev/null +++ b/pkg/tests/apis/provisioning/testdata/local-tmp.json @@ -0,0 +1,21 @@ +{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": { + "name": "local-tmp" + }, + "spec": { + "description": "load a /tmp directory", + "local": { + "path": "the test will replace this string" + }, + "sync": { + "enabled": false, + "target": "mirror", + "intervalSeconds": 60 + }, + "readOnly": false, + "title": "Load tmp dashboards", + "type": "local" + } +} \ No newline at end of file diff --git a/pkg/tests/apis/provisioning/testdata/local-tmp.yaml b/pkg/tests/apis/provisioning/testdata/local-tmp.yaml deleted file mode 100644 index cc04915c473..00000000000 --- a/pkg/tests/apis/provisioning/testdata/local-tmp.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: provisioning.grafana.app/v0alpha1 -kind: Repository -metadata: - name: local-tmp -spec: - title: Load tmp dashboards - description: load a /tmp directory - sync: - enabled: false - target: mirror - editing: - create: true - update: true - delete: true - type: local - local: - path: the test will replace this string diff --git a/pkg/tests/apis/provisioning/testdata/local-xxx.json b/pkg/tests/apis/provisioning/testdata/local-xxx.json new file mode 100644 index 00000000000..3f48e7d10c4 --- /dev/null +++ b/pkg/tests/apis/provisioning/testdata/local-xxx.json @@ -0,0 +1,21 @@ +{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": { + "name": "local-xxx" + }, + "spec": { + "description": "load resources from https://github.com/grafana/grafana/tree/main/conf/provisioning/sample", + "local": { + "path": "conf/provisioning/sample" + }, + "sync": { + "enabled": false, + "target": "mirror", + "intervalSeconds": 60 + }, + "readOnly": false, + "title": "Config provisioning files", + "type": "local" + } +} \ No newline at end of file diff --git a/pkg/tests/apis/provisioning/testdata/local-xxx.yaml b/pkg/tests/apis/provisioning/testdata/local-xxx.yaml deleted file mode 100644 index 6ad685bb519..00000000000 --- a/pkg/tests/apis/provisioning/testdata/local-xxx.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: provisioning.grafana.app/v0alpha1 -kind: Repository -metadata: - name: local-xxx -spec: - title: Config provisioning files - description: load resources from https://github.com/grafana/grafana/tree/main/conf/provisioning/sample - type: local - sync: - enabled: false - target: mirror - editing: - create: true - update: true - delete: true - local: - path: conf/provisioning/sample diff --git a/pkg/tests/apis/provisioning/testdata/s3-example.json b/pkg/tests/apis/provisioning/testdata/s3-example.json new file mode 100644 index 00000000000..0b12d551b16 --- /dev/null +++ b/pkg/tests/apis/provisioning/testdata/s3-example.json @@ -0,0 +1,22 @@ +{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": { + "name": "s3-example" + }, + "spec": { + "description": "load resources from an S3 bucket", + "s3": { + "bucket": "my-bucket", + "region": "us-west-1" + }, + "sync": { + "enabled": false, + "target": "mirror", + "intervalSeconds": 60 + }, + "readOnly": false, + "title": "S3 Example", + "type": "s3" + } +} \ No newline at end of file diff --git a/pkg/tests/apis/provisioning/testdata/s3-example.yaml b/pkg/tests/apis/provisioning/testdata/s3-example.yaml deleted file mode 100644 index 899a7201f44..00000000000 --- a/pkg/tests/apis/provisioning/testdata/s3-example.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: provisioning.grafana.app/v0alpha1 -kind: Repository -metadata: - name: s3-example -spec: - title: S3 Example - description: load resources from an S3 bucket - sync: - enabled: false - target: mirror - type: s3 - s3: - region: us-west-1 - bucket: my-bucket diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index 04c8fb80615..227eaeedeb2 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -1,5 +1,5 @@ import { Action, KBarProvider } from 'kbar'; -import { Component, ComponentType, Fragment } from 'react'; +import { Component, ComponentType, Fragment, ReactNode } from 'react'; import CacheProvider from 'react-inlinesvg/provider'; import { Provider } from 'react-redux'; import { Route, Routes } from 'react-router-dom-v5-compat'; @@ -37,6 +37,11 @@ interface AppWrapperState { /** Used by enterprise */ let bodyRenderHooks: ComponentType[] = []; let pageBanners: ComponentType[] = []; +const enterpriseProviders: Array> = []; + +export function addEnterpriseProviders(provider: ComponentType<{ children: ReactNode }>) { + enterpriseProviders.push(provider); +} export function addBodyRenderHook(fn: ComponentType) { bodyRenderHooks.push(fn); @@ -100,6 +105,7 @@ export class AppWrapper extends Component { routes: ready && this.renderRoutes(), pageBanners, bodyRenderHooks, + providers: enterpriseProviders, }; const MaybeTimeRangeProvider = config.featureToggles.timeRangeProvider ? TimeRangeProvider : Fragment; diff --git a/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts b/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts index 7a5a9ee61af..cecd28020cf 100644 --- a/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts +++ b/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts @@ -152,7 +152,7 @@ export class ElementSelection { const firstObj = this.selectedObjects?.values().next().value?.resolve(); if (firstObj instanceof VizPanel) { - return new MultiSelectedVizPanelsEditableElement(sceneObjects); + return new MultiSelectedVizPanelsEditableElement(sceneObjects.filter((obj) => obj instanceof VizPanel)); } if (isEditableDashboardElement(firstObj!)) { diff --git a/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx index 79724e4474e..3278deb3a17 100644 --- a/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx @@ -1,4 +1,5 @@ import { ReactNode } from 'react'; +import { v4 as uuidv4 } from 'uuid'; import { Stack, Text, Button } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; @@ -9,31 +10,32 @@ import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelec export class MultiSelectedObjectsEditableElement implements MultiSelectedEditableDashboardElement { public readonly isMultiSelectedEditableDashboardElement = true; public readonly typeName = 'Objects'; + public readonly key: string; - private items?: BulkActionElement[]; - - constructor(items: BulkActionElement[]) { - this.items = items; + constructor(private _elements: BulkActionElement[]) { + this.key = uuidv4(); } - public onDelete = () => { - for (const item of this.items || []) { - item.onDelete(); - } - }; - - renderActions(): ReactNode { + public renderActions(): ReactNode { return ( - No. of objects selected: - {this.items?.length} + + No. of objects selected: {{ length }} + - {config.featureToggles.queryLibrary && ( + {queryLibraryEnabled && ( - {!isClone && isEditing && ( - + {!isClone && isEditing && ( + - - - - ); -}; diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesList.test.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesList.test.tsx deleted file mode 100644 index 15ec78719e5..00000000000 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesList.test.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { render, waitFor, screen } from '@testing-library/react'; - -import { AnnoKeyCreatedBy } from '../../apiserver/types'; -import { ListQueryTemplateApiResponse } from '../../query-library/api/endpoints.gen'; - -import { QueryTemplatesList } from './QueryTemplatesList'; -import { QueryActionButtonProps } from './types'; - -let data: ListQueryTemplateApiResponse = { - items: [], -}; - -jest.mock('app/features/query-library', () => { - const actual = jest.requireActual('app/features/query-library'); - return { - ...actual, - useDeleteQueryTemplateMutation: () => [() => {}], - useListQueryTemplateQuery: () => { - return { - data: data, - isLoading: false, - error: null, - }; - }, - }; -}); - -jest.mock('./utils/dataFetching', () => { - return { - __esModule: true, - useLoadQueryMetadata: () => { - return { - loading: false, - value: [ - { - index: '0', - uid: '0', - datasourceName: 'prometheus', - datasourceRef: { type: 'prometheus', uid: 'Prometheus0' }, - datasourceType: 'prometheus', - createdAtTimestamp: 0, - query: { refId: 'A' }, - queryText: 'http_requests_total{job="test"}', - description: 'template0', - user: { - uid: 'viewer:JohnDoe', - displayName: 'John Doe', - avatarUrl: '', - }, - error: undefined, - }, - ], - }; - }, - useLoadUsers: () => { - return { - value: { - display: [ - { - avatarUrl: '', - displayName: 'john doe', - identity: { - name: 'JohnDoe', - type: 'viewer', - }, - }, - ], - }, - loading: false, - error: null, - }; - }, - }; -}); - -describe('QueryTemplatesList', () => { - it('renders empty state', async () => { - data = {}; - render(); - await waitFor(() => { - expect(screen.getByText(/You haven't saved any queries to your library yet/)).toBeInTheDocument(); - }); - }); - - it('renders query', async () => { - data.items = testItems; - render(); - await waitFor(() => { - // We don't really show query template title for some reason so creator name - expect(screen.getByText(/John Doe/)).toBeInTheDocument(); - }); - }); - - it('renders actionButton for query', async () => { - data.items = testItems; - let passedProps: QueryActionButtonProps; - - const queryActionButton = (props: QueryActionButtonProps) => { - passedProps = props; - return ; - }; - - render(); - await waitFor(() => { - // We don't really show query template title for some reason so creator name - expect(screen.getByText(/John Doe/)).toBeInTheDocument(); - expect(screen.getByText(/TEST_ACTION_BUTTON/)).toBeInTheDocument(); - // We didn't put much else into the query object but should be enough to check the prop - expect(passedProps.queries).toMatchObject([{ refId: 'A' }]); - }); - }); -}); - -const testItems = [ - { - metadata: { - name: 'TEST_QUERY', - creationTimestamp: '2025-01-01T11:11:11.00Z', - annotations: { - [AnnoKeyCreatedBy]: 'viewer:JohnDoe', - }, - }, - spec: { - title: 'Test Query title', - targets: [ - { - variables: {}, - properties: { - refId: 'A', - datasource: { - uid: 'Prometheus', - type: 'prometheus', - }, - }, - }, - ], - }, - }, -]; diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx deleted file mode 100644 index 946e3de9eea..00000000000 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { css } from '@emotion/css'; -import { uniqBy } from 'lodash'; -import { useEffect, useMemo, useState } from 'react'; - -import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { getAppEvents } from '@grafana/runtime'; -import { EmptyState, FilterInput, InlineLabel, MultiSelect, Spinner, useStyles2, Stack, Badge } from '@grafana/ui'; -import { t, Trans } from 'app/core/internationalization'; -import { useListQueryTemplateQuery } from 'app/features/query-library'; -import { QueryTemplate } from 'app/features/query-library/types'; - -import { convertDataQueryResponseToQueryTemplates } from '../../query-library/api/mappers'; - -import { QueryLibraryProps } from './QueryLibrary'; -import { queryLibraryTrackFilterDatasource } from './QueryLibraryAnalyticsEvents'; -import { QueryLibraryExpmInfo } from './QueryLibraryExpmInfo'; -import QueryTemplatesTable from './QueryTemplatesTable'; -import { useLoadQueryMetadata, useLoadUsers } from './utils/dataFetching'; -import { searchQueryLibrary } from './utils/search'; - -interface QueryTemplatesListProps extends QueryLibraryProps {} - -export function QueryTemplatesList(props: QueryTemplatesListProps) { - const { data: rawData, isLoading, error } = useListQueryTemplateQuery({}); - const data = useMemo(() => (rawData ? convertDataQueryResponseToQueryTemplates(rawData) : undefined), [rawData]); - const [isModalOpen, setIsModalOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [datasourceFilters, setDatasourceFilters] = useState>>( - props.activeDatasources?.map((ds) => ({ value: ds, label: ds })) || [] - ); - const [userFilters, setUserFilters] = useState>>([]); - const styles = useStyles2(getStyles); - - const loadUsersResult = useLoadUsersWithError(data); - const userNames = loadUsersResult.data ? loadUsersResult.data.display.map((user) => user.displayName) : []; - - const loadQueryMetadataResult = useLoadQueryMetadataWithError(data, loadUsersResult.data); - - // Filtering right now is done just on the frontend until there is better backend support for this. - const filteredRows = useMemo( - () => - searchQueryLibrary( - loadQueryMetadataResult.value || [], - searchQuery, - datasourceFilters.map((f) => f.value || ''), - userFilters.map((f) => f.value || '') - ), - [loadQueryMetadataResult.value, searchQuery, datasourceFilters, userFilters] - ); - - const datasourceNames = useMemo(() => { - return uniqBy(loadQueryMetadataResult.value, 'datasourceName').map((row) => row.datasourceName); - }, [loadQueryMetadataResult.value]); - - if (error instanceof Error) { - return ( - - {error.message} - - ); - } - - if (isLoading || loadUsersResult.isLoading || loadQueryMetadataResult.loading) { - return ; - } - - if (!data || data.length === 0) { - return ( - -

- { - "You haven't saved any queries to your library yet. Start adding them from Explore or your Query History tab." - } -

-
- ); - } - - return ( - <> - setIsModalOpen(false)} /> - - setSearchQuery(query)} - escapeRegex={false} - /> - - Datasource name(s): - - { - setDatasourceFilters(items); - actionMeta.action === 'select-option' && queryLibraryTrackFilterDatasource(); - }} - value={datasourceFilters} - options={datasourceNames.map((r) => { - return { value: r, label: r }; - })} - placeholder={'Filter queries for data sources(s)'} - aria-label={'Filter queries for data sources(s)'} - /> - - User name(s): - - { - setUserFilters(items); - actionMeta.action === 'select-option' && queryLibraryTrackFilterDatasource(); - }} - value={userFilters} - options={userNames.map((r) => { - return { value: r, label: r }; - })} - placeholder={'Filter queries for user name(s)'} - aria-label={'Filter queries for user name(s)'} - /> - setIsModalOpen(true)} - /> - - - - ); -} - -/** - * Wrap useLoadUsers with error handling. - * @param data - */ -function useLoadUsersWithError(data: QueryTemplate[] | undefined) { - const userUIDs = useMemo(() => data?.map((qt) => qt.user?.uid).filter((uid) => uid !== undefined), [data]); - const loadUsersResult = useLoadUsers(userUIDs); - useEffect(() => { - if (loadUsersResult.error) { - getAppEvents().publish({ - type: AppEvents.alertError.name, - payload: [ - t('query-library.user-info-get-error', 'Error attempting to get user info from the library: {{error}}', { - error: JSON.stringify(loadUsersResult.error), - }), - ], - }); - } - }, [loadUsersResult.error]); - return loadUsersResult; -} - -/** - * Wrap useLoadQueryMetadata with error handling. - * @param queryTemplates - * @param userDataList - */ -function useLoadQueryMetadataWithError( - queryTemplates: QueryTemplate[] | undefined, - userDataList: ReturnType['data'] -) { - const result = useLoadQueryMetadata(queryTemplates, userDataList); - - // useLoadQueryMetadata returns errors in the values so we filter and group them and later alert only one time for - // all the errors. This way we show data that is loaded even if some rows errored out. - // TODO: maybe we could show the rows with incomplete data to see exactly which ones errored out. I assume this - // can happen for example when data source for saved query was deleted. Would be nice if user would still be able - // to delete such row or decide what to do. - const [values, errors] = useMemo(() => { - let errors: Error[] = []; - let values = []; - if (!result.loading) { - for (const value of result.value!) { - if (value.error) { - errors.push(value.error); - } else { - values.push(value); - } - } - } - return [values, errors]; - }, [result]); - - useEffect(() => { - if (errors.length) { - getAppEvents().publish({ - type: AppEvents.alertError.name, - payload: [ - t('query-library.query-template-get-error', 'Error attempting to load query template metadata: {{error}}', { - error: JSON.stringify(errors), - }), - ], - }); - } - }, [errors]); - - return { - loading: result.loading, - value: values, - }; -} - -const getStyles = (theme: GrafanaTheme2) => ({ - searchInput: css({ - maxWidth: theme.spacing(55), - }), - multiSelect: css({ - maxWidth: theme.spacing(65), - }), - label: css({ - marginLeft: theme.spacing(1), - border: `1px solid ${theme.colors.secondary.border}`, - }), -}); diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx deleted file mode 100644 index 575f10bd063..00000000000 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useState } from 'react'; - -import { getAppEvents } from '@grafana/runtime'; -import { IconButton, Modal } from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; -import { createSuccessNotification } from 'app/core/copy/appNotification'; -import { t } from 'app/core/internationalization'; -import { useDeleteQueryTemplateMutation } from 'app/features/query-library'; -import { dispatch } from 'app/store/store'; -import { ShowConfirmModalEvent } from 'app/types/events'; - -import { - queryLibaryTrackDeleteQuery, - queryLibraryTrackAddOrEditDescription, - queryLibraryTrackRunQuery, -} from '../QueryLibraryAnalyticsEvents'; -import { QueryTemplateForm } from '../QueryTemplateForm'; -import { QueryActionButton } from '../types'; - -import { useQueryLibraryListStyles } from './styles'; -import { QueryTemplateRow } from './types'; - -interface ActionsCellProps { - queryUid?: string; - queryTemplate: QueryTemplateRow; - rootDatasourceUid?: string; - QueryActionButton?: QueryActionButton; -} - -function ActionsCell({ queryTemplate, rootDatasourceUid, queryUid, QueryActionButton }: ActionsCellProps) { - const [deleteQueryTemplate] = useDeleteQueryTemplateMutation(); - const [editFormOpen, setEditFormOpen] = useState(false); - const styles = useQueryLibraryListStyles(); - - const onDeleteQuery = (queryUid: string) => { - const performDelete = (queryUid: string) => { - deleteQueryTemplate({ - name: queryUid, - deleteOptions: {}, - }); - dispatch(notifyApp(createSuccessNotification(t('explore.query-library.query-deleted', 'Query deleted')))); - queryLibaryTrackDeleteQuery(); - }; - - getAppEvents().publish( - new ShowConfirmModalEvent({ - title: t('explore.query-library.delete-query-title', 'Delete query'), - text: t( - 'explore.query-library.delete-query-text', - "You're about to remove this query from the query library. This action cannot be undone. Do you want to continue?" - ), - yesText: t('query-library.delete-query-button', 'Delete query'), - icon: 'trash-alt', - onConfirm: () => performDelete(queryUid), - }) - ); - }; - - return ( -
- { - if (queryUid) { - onDeleteQuery(queryUid); - } - }} - /> - { - setEditFormOpen(true); - queryLibraryTrackAddOrEditDescription(); - }} - /> - {QueryActionButton && ( - { - queryLibraryTrackRunQuery(queryTemplate.datasourceType || ''); - }} - /> - )} - setEditFormOpen(false)} - > - setEditFormOpen(false)} - templateData={queryTemplate} - onSave={() => { - setEditFormOpen(false); - }} - /> - -
- ); -} - -export default ActionsCell; diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/AddedByCell.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/AddedByCell.tsx deleted file mode 100644 index 35793ab7fbd..00000000000 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/AddedByCell.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Avatar } from '@grafana/ui'; -import { User } from 'app/features/query-library/types'; - -import { useQueryLibraryListStyles } from './styles'; - -type AddedByCellProps = { - user?: User; -}; -export function AddedByCell(props: AddedByCellProps) { - const styles = useQueryLibraryListStyles(); - - return ( -
- - - - {props.user?.displayName || 'Unknown'} -
- ); -} diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/DatasourceTypeCell.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/DatasourceTypeCell.tsx deleted file mode 100644 index bdc361b7532..00000000000 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/DatasourceTypeCell.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { CellProps } from 'react-table'; - -import { useDatasource } from '../utils/useDatasource'; - -import { useQueryLibraryListStyles } from './styles'; -import { QueryTemplateRow } from './types'; - -export function DatasourceTypeCell(props: CellProps) { - const datasourceApi = useDatasource(props.row.original.datasourceRef); - const styles = useQueryLibraryListStyles(); - - return

{datasourceApi?.meta.name}

; -} diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/DateAddedCell.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/DateAddedCell.tsx deleted file mode 100644 index 29bf6a96b07..00000000000 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/DateAddedCell.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { CellProps } from 'react-table'; - -import { dateTime } from '@grafana/data'; - -import { useQueryLibraryListStyles } from './styles'; -import { QueryTemplateRow } from './types'; - -export function DateAddedCell(props: CellProps) { - const styles = useQueryLibraryListStyles(); - const formattedTime = dateTime(props.row.original.createdAtTimestamp).format('YYYY-MM-DD HH:mm:ss'); - - return

{formattedTime}

; -} diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx deleted file mode 100644 index f19e95f14c8..00000000000 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { css, cx } from '@emotion/css'; -import { CellProps } from 'react-table'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { Spinner, Tooltip, useStyles2 } from '@grafana/ui'; - -import { useDatasource } from '../utils/useDatasource'; - -import { useQueryLibraryListStyles } from './styles'; -import { QueryTemplateRow } from './types'; - -export function QueryDescriptionCell(props: CellProps) { - const datasourceApi = useDatasource(props.row.original.datasourceRef); - const queryLibraryListStyles = useQueryLibraryListStyles(); - const styles = useStyles2(getStyles); - - if (!datasourceApi) { - return ; - } - - if (!props.row.original.query) { - return
No queries
; - } - const queryDisplayText = props.row.original.queryText; - const description = props.row.original.description; - const dsName = props.row.original.datasourceName; - - return ( -
-

- {datasourceApi?.meta.info.description} - {dsName} -

- -

- {queryDisplayText} -

-
-

{description}

-
- ); -} - -const getStyles = (theme: GrafanaTheme2) => ({ - container: css({ - maxWidth: theme.spacing(60), - }), - queryDisplayText: css({ - backgroundColor: theme.colors.background.canvas, - }), -}); diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx deleted file mode 100644 index bd7b94e39ef..00000000000 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/index.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { css } from '@emotion/css'; -import { SortByFn } from 'react-table'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { Column, InteractiveTable, useStyles2 } from '@grafana/ui'; - -import { QueryActionButton } from '../types'; - -import ActionsCell from './ActionsCell'; -import { AddedByCell } from './AddedByCell'; -import { DatasourceTypeCell } from './DatasourceTypeCell'; -import { DateAddedCell } from './DateAddedCell'; -import { QueryDescriptionCell } from './QueryDescriptionCell'; -import { QueryTemplateRow } from './types'; - -const timestampSort: SortByFn = (rowA, rowB, _, desc) => { - const timeA = rowA.original.createdAtTimestamp || 0; - const timeB = rowB.original.createdAtTimestamp || 0; - return desc ? timeA - timeB : timeB - timeA; -}; - -function createColumns(queryActionButton?: QueryActionButton): Array> { - return [ - { id: 'description', header: 'Data source and query', cell: QueryDescriptionCell }, - { id: 'addedBy', header: 'Added by', cell: ({ row: { original } }) => }, - { id: 'datasourceType', header: 'Datasource type', cell: DatasourceTypeCell, sortType: 'string' }, - { id: 'createdAtTimestamp', header: 'Date added', cell: DateAddedCell, sortType: timestampSort }, - { - id: 'actions', - header: '', - cell: ({ row: { original } }) => ( - - ), - }, - ]; -} - -type Props = { - queryTemplateRows: QueryTemplateRow[]; - queryActionButton?: QueryActionButton; -}; - -export default function QueryTemplatesTable({ queryTemplateRows, queryActionButton }: Props) { - const styles = useStyles2(getStyles); - const columns = createColumns(queryActionButton); - - return ( - row.index} - pageSize={20} - className={styles.table} - /> - ); -} - -const getStyles = (theme: GrafanaTheme2) => ({ - table: css({ - 'tbody tr': { - position: 'relative', - backgroundColor: theme.colors.background.secondary, - borderCollapse: 'collapse', - borderBottom: 'unset', - overflow: 'hidden', // Ensure the row doesn't overflow and cause additonal scrollbars - }, - /* Adds the pseudo-element for the lines between table rows */ - 'tbody tr::after': { - content: '""', - position: 'absolute', - inset: 'auto 0 0 0', - height: theme.spacing(0.5), - backgroundColor: theme.colors.background.primary, - }, - }), -}); diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/styles.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/styles.tsx deleted file mode 100644 index 715cd5d235b..00000000000 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/styles.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { css } from '@emotion/css'; - -import { GrafanaTheme2 } from '@grafana/data/'; -import { useStyles2 } from '@grafana/ui/'; - -export const useQueryLibraryListStyles = () => { - return useStyles2(getStyles); -}; - -const getStyles = (theme: GrafanaTheme2) => ({ - logo: css({ - marginRight: theme.spacing(2), - width: '16px', - }), - header: css({ - margin: 0, - fontSize: theme.typography.h5.fontSize, - color: theme.colors.text.secondary, - }), - mainText: css({ - margin: 0, - fontSize: theme.typography.body.fontSize, - textOverflow: 'ellipsis', - }), - otherText: css({ - margin: 0, - fontSize: theme.typography.body.fontSize, - color: theme.colors.text.secondary, - textOverflow: 'ellipsis', - }), - singleLine: css({ - display: '-webkit-box', - WebkitBoxOrient: 'vertical', - WebkitLineClamp: 1, - overflow: 'hidden', - }), - cell: css({ - display: 'flex', - alignItems: 'center', - '&:last-child': { - justifyContent: 'end', - }, - }), - actionButton: css({ - padding: theme.spacing(1), - }), -}); diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/types.ts b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/types.ts deleted file mode 100644 index 51f5e490953..00000000000 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { DataQuery, DataSourceRef } from '@grafana/schema'; -import { User } from 'app/features/query-library/types'; - -export type QueryTemplateRow = { - index: string; - datasourceName?: string; - description?: string; - query?: DataQuery; - queryText?: string; - datasourceRef?: DataSourceRef | null; - datasourceType?: string; - createdAtTimestamp?: number; - user?: User; - uid?: string; -}; diff --git a/public/app/features/explore/QueryLibrary/SaveQueryButton.tsx b/public/app/features/explore/QueryLibrary/SaveQueryButton.tsx deleted file mode 100644 index 07c75179ed6..00000000000 --- a/public/app/features/explore/QueryLibrary/SaveQueryButton.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useLocalStorage } from 'react-use'; - -import { DataQuery } from '@grafana/schema'; -import { Badge } from '@grafana/ui'; - -import { QueryOperationAction } from '../../../core/components/QueryOperationRow/QueryOperationAction'; -import { t } from '../../../core/internationalization'; - -import { QUERY_LIBRARY_LOCAL_STORAGE_KEYS } from './QueryLibrary'; -import { useQueryLibraryContext } from './QueryLibraryContext'; - -interface Props { - query: DataQuery; -} - -export function SaveQueryButton({ query }: Props) { - const { openAddQueryModal } = useQueryLibraryContext(); - - const [showQueryLibraryBadgeButton, setShowQueryLibraryBadgeButton] = useLocalStorage( - QUERY_LIBRARY_LOCAL_STORAGE_KEYS.explore.newButton, - true - ); - - return showQueryLibraryBadgeButton ? ( - { - openAddQueryModal(query); - setShowQueryLibraryBadgeButton(false); - }} - style={{ cursor: 'pointer' }} - /> - ) : ( - { - openAddQueryModal(query); - }} - /> - ); -} diff --git a/public/app/features/explore/QueryLibrary/utils/dataFetching.ts b/public/app/features/explore/QueryLibrary/utils/dataFetching.ts deleted file mode 100644 index e874d6a331e..00000000000 --- a/public/app/features/explore/QueryLibrary/utils/dataFetching.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { skipToken } from '@reduxjs/toolkit/query'; -import { compact, uniq } from 'lodash'; -import { useAsync } from 'react-use'; -import { AsyncState } from 'react-use/lib/useAsync'; - -import { getDataSourceSrv } from '@grafana/runtime'; -import { DataQuery, DataSourceRef } from '@grafana/schema'; - -import { createQueryText } from '../../../../core/utils/richHistory'; -import { useGetDisplayMappingQuery } from '../../../iam'; -import { getDatasourceSrv } from '../../../plugins/datasource_srv'; -import { QueryTemplate } from '../../../query-library/types'; - -export function useLoadUsers(userUIDs: string[] | undefined) { - const userQtList = uniq(compact(userUIDs)); - return useGetDisplayMappingQuery( - userUIDs - ? { - key: userQtList, - } - : skipToken - ); -} - -// Explicitly type the result so TS knows to discriminate between the error result and good result by the error prop -// value. -type MetadataValue = - | { - index: string; - uid: string; - datasourceName: string; - datasourceRef: DataSourceRef | undefined | null; - datasourceType: string; - createdAtTimestamp: number; - query: DataQuery; - queryText: string; - description: string; - user: { - uid: string; - displayName: string; - avatarUrl: string; - }; - error: undefined; - } - | { - index: string; - error: Error; - }; - -/** - * Map metadata to query templates we get from the DB. - * @param queryTemplates - * @param userDataList - */ -export function useLoadQueryMetadata( - queryTemplates: QueryTemplate[] | undefined, - userDataList: ReturnType['data'] -): AsyncState { - return useAsync(async () => { - if (!(queryTemplates && userDataList)) { - return []; - } - - const rowsPromises = queryTemplates.map( - async (queryTemplate: QueryTemplate, index: number): Promise => { - try { - const datasourceRef = queryTemplate.targets[0]?.datasource; - const datasourceApi = await getDataSourceSrv().get(datasourceRef); - const datasourceType = getDatasourceSrv().getInstanceSettings(datasourceRef)?.meta.name || ''; - const query = queryTemplate.targets[0]; - const queryText = createQueryText(query, datasourceApi); - const datasourceName = datasourceApi?.name || ''; - const extendedUserData = userDataList.display.find( - (user) => `${user?.identity.type}:${user?.identity.name}` === queryTemplate.user?.uid - ); - - return { - index: index.toString(), - uid: queryTemplate.uid, - datasourceName, - datasourceRef, - datasourceType, - createdAtTimestamp: queryTemplate?.createdAtTimestamp || 0, - query, - queryText, - description: queryTemplate.title, - user: { - uid: queryTemplate.user?.uid || '', - displayName: extendedUserData?.displayName || '', - avatarUrl: extendedUserData?.avatarURL || '', - }, - error: undefined, - }; - } catch (error) { - // Instead of throwing we collect the errors in the result so upstream code can decide what to do. - return { - index: index.toString(), - error: error instanceof Error ? error : new Error('unknown error ' + JSON.stringify(error)), - }; - } - } - ); - - return Promise.all(rowsPromises); - }, [queryTemplates, userDataList]); -} diff --git a/public/app/features/explore/QueryLibrary/utils/search.ts b/public/app/features/explore/QueryLibrary/utils/search.ts deleted file mode 100644 index 2c55be7e9d0..00000000000 --- a/public/app/features/explore/QueryLibrary/utils/search.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { QueryTemplateRow } from '../QueryTemplatesTable/types'; - -export const searchQueryLibrary = ( - queryLibrary: QueryTemplateRow[], - query: string, - dsFilters: string[], - userNameFilters: string[] -) => { - const result = queryLibrary.filter((item) => { - const matchesDsFilter = - dsFilters.length === 0 || dsFilters.some((f) => item.datasourceName?.toLowerCase().includes(f.toLowerCase())); - const matchesUserNameFilter = - userNameFilters.length === 0 || userNameFilters.includes(item.user?.displayName || ''); - return ( - (item.datasourceName?.toLowerCase().includes(query.toLowerCase()) || - item.datasourceType?.toLowerCase().includes(query.toLowerCase()) || - item.description?.toLowerCase().includes(query.toLowerCase()) || - item.queryText?.toLowerCase().includes(query.toLowerCase())) && - matchesDsFilter && - matchesUserNameFilter - ); - }); - return result; -}; diff --git a/public/app/features/explore/QueryLibrary/utils/useDatasource.tsx b/public/app/features/explore/QueryLibrary/utils/useDatasource.tsx deleted file mode 100644 index 1ea4fd29b76..00000000000 --- a/public/app/features/explore/QueryLibrary/utils/useDatasource.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { useAsync } from 'react-use'; - -import { getDataSourceSrv } from '@grafana/runtime'; -import { DataSourceRef } from '@grafana/schema'; - -export function useDatasource(dataSourceRef?: DataSourceRef | null) { - const { value } = useAsync(async () => await getDataSourceSrv().get(dataSourceRef), [dataSourceRef]); - return value; -} diff --git a/public/app/features/explore/RichHistory/RichHistoryAddToLibrary.tsx b/public/app/features/explore/RichHistory/RichHistoryAddToLibrary.tsx index e6aa0e25143..8e7a057a453 100644 --- a/public/app/features/explore/RichHistory/RichHistoryAddToLibrary.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryAddToLibrary.tsx @@ -1,57 +1,32 @@ import { useState } from 'react'; import { DataQuery } from '@grafana/schema'; -import { Button, Modal } from '@grafana/ui'; +import { Button } from '@grafana/ui'; import { t } from 'app/core/internationalization'; -import { isQueryLibraryEnabled, useListQueryTemplateQuery } from 'app/features/query-library'; -import { - queryLibraryTrackAddFromQueryHistory, - queryLibraryTrackAddFromQueryHistoryAddModalShown, -} from '../QueryLibrary/QueryLibraryAnalyticsEvents'; -import { QueryTemplateForm } from '../QueryLibrary/QueryTemplateForm'; +import { useQueryLibraryContext } from '../QueryLibrary/QueryLibraryContext'; type Props = { query: DataQuery; }; export const RichHistoryAddToLibrary = ({ query }: Props) => { - const { refetch } = useListQueryTemplateQuery({}); - const [isOpen, setIsOpen] = useState(false); const [hasBeenSaved, setHasBeenSaved] = useState(false); + const { openAddQueryModal, queryLibraryEnabled } = useQueryLibraryContext(); const buttonLabel = t('explore.rich-history-card.add-to-library', 'Add to library'); - return isQueryLibraryEnabled() && !hasBeenSaved ? ( + return queryLibraryEnabled && !hasBeenSaved ? ( <> - setIsOpen(false)} - > - setIsOpen(() => false)} - queryToAdd={query} - onSave={(isSuccess) => { - if (isSuccess) { - setIsOpen(false); - setHasBeenSaved(true); - refetch(); - queryLibraryTrackAddFromQueryHistory(query.datasource?.type || ''); - } - }} - /> - ) : undefined; }; diff --git a/public/app/features/explore/spec/helper/setup.tsx b/public/app/features/explore/spec/helper/setup.tsx index c57cf52c435..78aa044cab3 100644 --- a/public/app/features/explore/spec/helper/setup.tsx +++ b/public/app/features/explore/spec/helper/setup.tsx @@ -4,6 +4,7 @@ import { createMemoryHistory } from 'history'; import { KBarProvider } from 'kbar'; import { fromPairs } from 'lodash'; import { stringify } from 'querystring'; +import { ComponentType, ReactNode } from 'react'; import { Provider } from 'react-redux'; // eslint-disable-next-line no-restricted-imports import { Route, Router } from 'react-router-dom'; @@ -47,7 +48,6 @@ import { ExploreQueryParams } from '../../../../types'; import { initialUserState } from '../../../profile/state/reducers'; import ExplorePage from '../../ExplorePage'; import { QueriesDrawerContextProvider } from '../../QueriesDrawer/QueriesDrawerContext'; -import { QueryLibraryContextProvider } from '../../QueryLibrary/QueryLibraryContext'; type DatasourceSetup = { settings: DataSourceInstanceSettings; api: DataSourceApi }; @@ -60,6 +60,7 @@ type SetupOptions = { failAddToLibrary?: boolean; // Use AppChrome wrapper around ExplorePage - needed to test query library/history withAppChrome?: boolean; + provider?: ComponentType<{ children: ReactNode }>; }; type TearDownOptions = { @@ -179,12 +180,18 @@ export function setupExplore(options?: SetupOptions): { const contextMock = getGrafanaContextMock({ location }); + const FinalProvider = + options?.provider || + (({ children }) => { + return children; + }); + const { unmount, container } = render( - - + + {options?.withAppChrome ? ( @@ -204,8 +211,8 @@ export function setupExplore(options?: SetupOptions): { render={(props) => } /> )} - - + + diff --git a/public/app/features/explore/spec/queryLibrary.test.tsx b/public/app/features/explore/spec/queryLibrary.test.tsx deleted file mode 100644 index 8de9796fdf0..00000000000 --- a/public/app/features/explore/spec/queryLibrary.test.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { Props } from 'react-virtualized-auto-sizer'; - -import { EventBusSrv } from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { DataQuery } from '@grafana/schema/dist/esm/veneer/common.types'; - -import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; - -import { - assertAddToQueryLibraryButtonExists, - assertQueryHistory, - assertQueryLibraryTemplateExists, -} from './helper/assert'; -import { - addQueryHistoryToQueryLibrary, - openQueryHistory, - openQueryLibrary, - submitAddToQueryLibrary, -} from './helper/interactions'; -import { setupExplore, waitForExplore } from './helper/setup'; - -const reportInteractionMock = jest.fn(); -const testEventBus = new EventBusSrv(); -testEventBus.publish = jest.fn(); - -interface MockQuery extends DataQuery { - expr: string; -} - -jest.mock('../QueryLibrary/utils/dataFetching', () => { - return { - __esModule: true, - ...jest.requireActual('../QueryLibrary/utils/dataFetching'), - useLoadUsers: () => { - return { - data: { - display: [ - { - avatarUrl: '', - displayName: 'john doe', - identity: { - name: 'JohnDoe', - type: 'viewer', - }, - }, - ], - }, - isLoading: false, - error: null, - }; - }, - }; -}); - -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - reportInteraction: (...args: object[]) => { - reportInteractionMock(...args); - }, - getAppEvents: () => testEventBus, - usePluginLinks: jest.fn().mockReturnValue({ links: [] }), -})); - -jest.mock('app/core/core', () => ({ - contextSrv: { - hasPermission: () => true, - isSignedIn: true, - getValidIntervals: (defaultIntervals: string[]) => defaultIntervals, - user: { - isSignedIn: true, - }, - }, -})); - -jest.mock('app/core/services/PreferencesService', () => ({ - PreferencesService: function () { - return { - patch: jest.fn(), - load: jest.fn().mockResolvedValue({ - queryHistory: { - homeTab: 'query', - }, - }), - }; - }, -})); - -jest.mock('../hooks/useExplorePageTitle', () => ({ - useExplorePageTitle: jest.fn(), -})); - -jest.mock('react-virtualized-auto-sizer', () => { - return { - __esModule: true, - default(props: Props) { - return
{props.children({ height: 1, scaledHeight: 1, scaledWidth: 1000, width: 1000 })}
; - }, - }; -}); - -function setupQueryLibrary() { - const mockQuery: MockQuery = { refId: 'TEST', expr: 'TEST' }; - setupExplore({ - queryHistory: { - queryHistory: [{ datasourceUid: 'loki', queries: [mockQuery] }], - totalCount: 1, - }, - withAppChrome: true, - }); -} - -let previousQueryLibraryEnabled: boolean | undefined; -let previousQueryHistoryEnabled: boolean; - -describe('QueryLibrary', () => { - silenceConsoleOutput(); - - beforeAll(() => { - previousQueryLibraryEnabled = config.featureToggles.queryLibrary; - previousQueryHistoryEnabled = config.queryHistoryEnabled; - - config.featureToggles.queryLibrary = true; - config.queryHistoryEnabled = true; - }); - - afterAll(() => { - config.featureToggles.queryLibrary = previousQueryLibraryEnabled; - config.queryHistoryEnabled = previousQueryHistoryEnabled; - jest.restoreAllMocks(); - }); - - it('Load query templates', async () => { - setupQueryLibrary(); - await waitForExplore(); - await openQueryLibrary(); - await assertQueryLibraryTemplateExists('loki', 'Loki Query Template'); - }); - - it('Shows add to query library button only when the toggle is enabled', async () => { - setupQueryLibrary(); - await waitForExplore(); - await openQueryHistory(); - await assertQueryHistory(['{"expr":"TEST"}']); - await assertAddToQueryLibraryButtonExists(true); - }); - - it('Does not show the query library button when the toggle is disabled', async () => { - config.featureToggles.queryLibrary = false; - setupQueryLibrary(); - await waitForExplore(); - await openQueryHistory(); - await assertQueryHistory(['{"expr":"TEST"}']); - await assertAddToQueryLibraryButtonExists(false); - config.featureToggles.queryLibrary = true; - }); - - it('Shows a notification when a template is added and hides the add button', async () => { - setupQueryLibrary(); - await waitForExplore(); - await openQueryHistory(); - await assertQueryHistory(['{"expr":"TEST"}']); - await addQueryHistoryToQueryLibrary(); - await submitAddToQueryLibrary({ description: 'Test' }); - expect(testEventBus.publish).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'alert-success', - payload: ['Query successfully saved to the library'], - }) - ); - await assertAddToQueryLibraryButtonExists(false); - }); -}); diff --git a/public/app/features/query-library/index.ts b/public/app/features/query-library/index.ts index 246ca735681..7f0c6f1c325 100644 --- a/public/app/features/query-library/index.ts +++ b/public/app/features/query-library/index.ts @@ -7,8 +7,6 @@ * @alpha */ -import { config } from '@grafana/runtime'; - import { QUERY_LIBRARY_GET_LIMIT } from './api/api'; import { generatedQueryLibraryApi } from './api/endpoints.gen'; import { mockData } from './api/mocks'; @@ -46,10 +44,6 @@ export const { }, }); -export function isQueryLibraryEnabled() { - return config.featureToggles.queryLibrary; -} - export const QueryLibraryMocks = { data: mockData.all, }; diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index a3dc05930d7..36a3d5818ee 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -8,7 +8,6 @@ import { PureComponent, ReactNode } from 'react'; // Utils & Services import { CoreApp, - DataQuery, DataSourceApi, DataSourceInstanceSettings, DataSourcePluginContextProvider, @@ -24,7 +23,8 @@ import { toLegacyResponseData, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { AngularComponent, config, getAngularLoader, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; +import { AngularComponent, getAngularLoader, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; import { Badge, ErrorBoundaryAlert } from '@grafana/ui'; import { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp'; import { @@ -40,7 +40,7 @@ import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; -import { SaveQueryButton as SaveQueryToQueryLibraryButton } from '../../explore/QueryLibrary/SaveQueryButton'; +import { useQueryLibraryContext } from '../../explore/QueryLibrary/QueryLibraryContext'; import { QueryActionComponent, RowActionComponents } from './QueryActionComponent'; import { QueryEditorRowHeader } from './QueryEditorRowHeader'; @@ -489,7 +489,7 @@ export class QueryEditorRow extends PureComponent )} {this.renderExtraActions()} - {config.featureToggles.queryLibrary && } + > }) { + return props.providers.reduce((tree, Provider): ReactNode => { + return {tree}; + }, props.children); +} type RouterWrapperProps = { routes?: JSX.Element | false; bodyRenderHooks: ComponentType[]; pageBanners: ComponentType[]; + providers: Array>; }; export function RouterWrapper(props: RouterWrapperProps) { return ( @@ -31,7 +37,7 @@ export function RouterWrapper(props: RouterWrapperProps) { - + @@ -48,7 +54,7 @@ export function RouterWrapper(props: RouterWrapperProps) { - + diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 66492eac6b1..f6a71c1d0c5 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -925,12 +925,6 @@ "alt-action": "Delete row only", "text": "Are you sure you want to remove this row and all its panels?", "title": "Delete row" - }, - "repeat": { - "warning": { - "learn-more": "Learn more", - "text": "Panels in this row use the {{SHARED_DASHBOARD_QUERY}} data source. These panels will reference the panel in the original row, not the ones in the repeated rows." - } } }, "row-options": { @@ -939,7 +933,13 @@ }, "form": { "cancel": "Cancel", - "repeat-for": "Repeat for", + "repeat-for": { + "label": "Repeat for", + "learn-more": "Learn more", + "warning": { + "text": "Panels in this row use the {{SHARED_DASHBOARD_QUERY}} data source. These panels will reference the panel in the original row, not the ones in the repeated rows." + } + }, "title": "Title", "update": "Update" }, @@ -951,20 +951,23 @@ "edit-pane": { "objects": { "multi-select": { - "selection-number": "No. of objects selected: " + "selection-number": "No. of objects selected: {{length}}" } }, "open": "Open options pane", "panels": { "multi-select": { - "selection-number": "No. of panels selected: " + "selection-number": "No. of panels selected: {{length}}" } }, "row": { - "hide": "Hide row header", + "header": { + "hide": "Hide", + "title": "Row header" + }, "multi-select": { "options-header": "Multi-selected Row options", - "selection-number": "No. of rows selected: " + "selection-number": "No. of rows selected: {{length}}" } } }, @@ -1346,36 +1349,6 @@ "scan-for-older-logs": "Scan for older logs", "stop-scan": "Stop scan" }, - "query-library": { - "add-edit-description": "Add/edit description", - "cancel": "Cancel", - "default-description": "Public", - "delete-query": "Delete query", - "delete-query-text": "You're about to remove this query from the query library. This action cannot be undone. Do you want to continue?", - "delete-query-title": "Delete query", - "private": "Private", - "public": "Public", - "query-deleted": "Query deleted", - "query-template-add-error": "Error attempting to save this query to the library", - "query-template-added": "Query successfully saved to the library", - "query-template-edit-error": "Error attempting to edit this query", - "query-template-edited": "Query template successfully edited", - "save": "Save" - }, - "query-template-modal": { - "add-info": "You're about to save this query. Once saved, you can easily access it in the Query Library tab for future use and reference.", - "add-title": "Add query to Query Library", - "auto-star": "Auto-star this query to add it to your starred list in the Query Library.", - "data-source-name": "Data source name", - "description": "Description", - "edit-info": "You're about to edit this query. Once saved, you can easily access it in the Query Library tab for future use and reference.", - "edit-title": "Edit query", - "query": "Query", - "visibility": "Visibility" - }, - "query-template-modall": { - "data-source-type": "Data source type" - }, "rich-history": { "close-tooltip": "Close query history", "datasource-a-z": "Data source A-Z", @@ -2993,14 +2966,6 @@ "role-label": "Role" } }, - "query-library": { - "datasource-names": "Datasource name(s):", - "delete-query-button": "Delete query", - "query-template-get-error": "Error attempting to load query template metadata: {{error}}", - "search": "Search by data source, query content or description", - "user-info-get-error": "Error attempting to get user info from the library: {{error}}", - "user-names": "User name(s):" - }, "query-operation": { "header": { "collapse-row": "Collapse query row", @@ -3010,8 +2975,6 @@ "expand-row": "Expand query row", "hide-response": "Hide response", "remove-query": "Remove query", - "save-to-query-library": "Save to query library", - "save-to-query-library-new": "New: Save to query library", "show-response": "Show response", "toggle-edit-mode": "Toggle text edit mode" }, diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 193643a5111..cf8e436c473 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -925,12 +925,6 @@ "alt-action": "Đęľęŧę řőŵ őʼnľy", "text": "Åřę yőū şūřę yőū ŵäʼnŧ ŧő řęmővę ŧĥįş řőŵ äʼnđ äľľ įŧş päʼnęľş?", "title": "Đęľęŧę řőŵ" - }, - "repeat": { - "warning": { - "learn-more": "Ŀęäřʼn mőřę", - "text": "Päʼnęľş įʼn ŧĥįş řőŵ ūşę ŧĥę {{SHARED_DASHBOARD_QUERY}} đäŧä şőūřčę. Ŧĥęşę päʼnęľş ŵįľľ řęƒęřęʼnčę ŧĥę päʼnęľ įʼn ŧĥę őřįģįʼnäľ řőŵ, ʼnőŧ ŧĥę őʼnęş įʼn ŧĥę řępęäŧęđ řőŵş." - } } }, "row-options": { @@ -939,7 +933,13 @@ }, "form": { "cancel": "Cäʼnčęľ", - "repeat-for": "Ŗępęäŧ ƒőř", + "repeat-for": { + "label": "Ŗępęäŧ ƒőř", + "learn-more": "Ŀęäřʼn mőřę", + "warning": { + "text": "Päʼnęľş įʼn ŧĥįş řőŵ ūşę ŧĥę {{SHARED_DASHBOARD_QUERY}} đäŧä şőūřčę. Ŧĥęşę päʼnęľş ŵįľľ řęƒęřęʼnčę ŧĥę päʼnęľ įʼn ŧĥę őřįģįʼnäľ řőŵ, ʼnőŧ ŧĥę őʼnęş įʼn ŧĥę řępęäŧęđ řőŵş." + } + }, "title": "Ŧįŧľę", "update": "Ůpđäŧę" }, @@ -951,20 +951,23 @@ "edit-pane": { "objects": { "multi-select": { - "selection-number": "Ńő. őƒ őþĵęčŧş şęľęčŧęđ: " + "selection-number": "Ńő. őƒ őþĵęčŧş şęľęčŧęđ: {{length}}" } }, "open": "Øpęʼn őpŧįőʼnş päʼnę", "panels": { "multi-select": { - "selection-number": "Ńő. őƒ päʼnęľş şęľęčŧęđ: " + "selection-number": "Ńő. őƒ päʼnęľş şęľęčŧęđ: {{length}}" } }, "row": { - "hide": "Ħįđę řőŵ ĥęäđęř", + "header": { + "hide": "Ħįđę", + "title": "Ŗőŵ ĥęäđęř" + }, "multi-select": { "options-header": "Mūľŧį-şęľęčŧęđ Ŗőŵ őpŧįőʼnş", - "selection-number": "Ńő. őƒ řőŵş şęľęčŧęđ: " + "selection-number": "Ńő. őƒ řőŵş şęľęčŧęđ: {{length}}" } } }, @@ -1346,36 +1349,6 @@ "scan-for-older-logs": "Ŝčäʼn ƒőř őľđęř ľőģş", "stop-scan": "Ŝŧőp şčäʼn" }, - "query-library": { - "add-edit-description": "Åđđ/ęđįŧ đęşčřįpŧįőʼn", - "cancel": "Cäʼnčęľ", - "default-description": "Pūþľįč", - "delete-query": "Đęľęŧę qūęřy", - "delete-query-text": "Ÿőū'řę äþőūŧ ŧő řęmővę ŧĥįş qūęřy ƒřőm ŧĥę qūęřy ľįþřäřy. Ŧĥįş äčŧįőʼn čäʼnʼnőŧ þę ūʼnđőʼnę. Đő yőū ŵäʼnŧ ŧő čőʼnŧįʼnūę?", - "delete-query-title": "Đęľęŧę qūęřy", - "private": "Přįväŧę", - "public": "Pūþľįč", - "query-deleted": "Qūęřy đęľęŧęđ", - "query-template-add-error": "Ēřřőř äŧŧęmpŧįʼnģ ŧő şävę ŧĥįş qūęřy ŧő ŧĥę ľįþřäřy", - "query-template-added": "Qūęřy şūččęşşƒūľľy şävęđ ŧő ŧĥę ľįþřäřy", - "query-template-edit-error": "Ēřřőř äŧŧęmpŧįʼnģ ŧő ęđįŧ ŧĥįş qūęřy", - "query-template-edited": "Qūęřy ŧęmpľäŧę şūččęşşƒūľľy ęđįŧęđ", - "save": "Ŝävę" - }, - "query-template-modal": { - "add-info": "Ÿőū'řę äþőūŧ ŧő şävę ŧĥįş qūęřy. Øʼnčę şävęđ, yőū čäʼn ęäşįľy äččęşş įŧ įʼn ŧĥę Qūęřy Ŀįþřäřy ŧäþ ƒőř ƒūŧūřę ūşę äʼnđ řęƒęřęʼnčę.", - "add-title": "Åđđ qūęřy ŧő Qūęřy Ŀįþřäřy", - "auto-star": "Åūŧő-şŧäř ŧĥįş qūęřy ŧő äđđ įŧ ŧő yőūř şŧäřřęđ ľįşŧ įʼn ŧĥę Qūęřy Ŀįþřäřy.", - "data-source-name": "Đäŧä şőūřčę ʼnämę", - "description": "Đęşčřįpŧįőʼn", - "edit-info": "Ÿőū'řę äþőūŧ ŧő ęđįŧ ŧĥįş qūęřy. Øʼnčę şävęđ, yőū čäʼn ęäşįľy äččęşş įŧ įʼn ŧĥę Qūęřy Ŀįþřäřy ŧäþ ƒőř ƒūŧūřę ūşę äʼnđ řęƒęřęʼnčę.", - "edit-title": "Ēđįŧ qūęřy", - "query": "Qūęřy", - "visibility": "Vįşįþįľįŧy" - }, - "query-template-modall": { - "data-source-type": "Đäŧä şőūřčę ŧypę" - }, "rich-history": { "close-tooltip": "Cľőşę qūęřy ĥįşŧőřy", "datasource-a-z": "Đäŧä şőūřčę Å-Ż", @@ -2993,14 +2966,6 @@ "role-label": "Ŗőľę" } }, - "query-library": { - "datasource-names": "Đäŧäşőūřčę ʼnämę(ş):", - "delete-query-button": "Đęľęŧę qūęřy", - "query-template-get-error": "Ēřřőř äŧŧęmpŧįʼnģ ŧő ľőäđ qūęřy ŧęmpľäŧę męŧäđäŧä: {{error}}", - "search": "Ŝęäřčĥ þy đäŧä şőūřčę, qūęřy čőʼnŧęʼnŧ őř đęşčřįpŧįőʼn", - "user-info-get-error": "Ēřřőř äŧŧęmpŧįʼnģ ŧő ģęŧ ūşęř įʼnƒő ƒřőm ŧĥę ľįþřäřy: {{error}}", - "user-names": "Ůşęř ʼnämę(ş):" - }, "query-operation": { "header": { "collapse-row": "Cőľľäpşę qūęřy řőŵ", @@ -3010,8 +2975,6 @@ "expand-row": "Ēχpäʼnđ qūęřy řőŵ", "hide-response": "Ħįđę řęşpőʼnşę", "remove-query": "Ŗęmővę qūęřy", - "save-to-query-library": "Ŝävę ŧő qūęřy ľįþřäřy", - "save-to-query-library-new": "Ńęŵ: Ŝävę ŧő qūęřy ľįþřäřy", "show-response": "Ŝĥőŵ řęşpőʼnşę", "toggle-edit-mode": "Ŧőģģľę ŧęχŧ ęđįŧ mőđę" },