Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
118 files changed
+1883
-3417
No files matched your search
@@ -4744,27 +4744,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
|
||||
],
|
||||
"public/app/features/explore/QueryLibrary/QueryLibraryExpmInfo.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> 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 <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> 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 <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
|
||||
@@ -7,7 +7,7 @@ checktype: {
|
||||
versions: {
|
||||
"v0alpha1": {
|
||||
codegen: {
|
||||
frontend: false
|
||||
frontend: true
|
||||
backend: true
|
||||
}
|
||||
schema: {
|
||||
|
||||
@@ -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 <a href='https://grafana.com/docs/grafana/latest/upgrade-guide/upgrade-v11.2/#grafana-data-source-uid-format-enforcement' target=_blank>documentation</a> 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 <a href='https://grafana.com/docs/grafana/latest/upgrade-guide/upgrade-v11.2/#grafana-data-source-uid-format-enforcement' target=_blank>documentation</a> 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 <a href='/connections/datasources/edit/%s'>data source configuration</a>"+
|
||||
" 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 <a href='/connections/datasources/edit/%s'>data source configuration</a>"+
|
||||
" and address the issues reported.", ds.UID),
|
||||
s.ID(),
|
||||
ds.UID,
|
||||
), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type pluginContextProvider interface {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <a href='https://grafana.com/legal/plugin-deprecation/#a-plugin-i-use-is-deprecated-what-should-i-do' target=_blank>documentation</a> 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 <a href='https://grafana.com/legal/plugin-deprecation/#a-plugin-i-use-is-deprecated-what-should-i-do' target=_blank>documentation</a> 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 <a href='/plugins/%s?page=version-history'>plugin admin page</a>"+
|
||||
" 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 <a href='/plugins/%s?page=version-history'>plugin admin page</a>"+
|
||||
" 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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -257,4 +257,5 @@ export interface FeatureToggles {
|
||||
alertingAlertmanagerExtraDedupStageStopPipeline?: boolean;
|
||||
newLogsPanel?: boolean;
|
||||
grafanaconThemes?: boolean;
|
||||
pluginsCDNSyncLoader?: boolean;
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export const ScrollContainer = forwardRef<HTMLDivElement, PropsWithChildren<Prop
|
||||
const defaults: Partial<BoxProps> = {
|
||||
maxHeight: '100%',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
};
|
||||
const boxProps = { ...defaults, ...rest };
|
||||
|
||||
|
||||
+4
-10
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;`
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1794,6 +1794,12 @@ var (
|
||||
HideFromDocs: true,
|
||||
RequiresRestart: true,
|
||||
},
|
||||
{
|
||||
Name: "pluginsCDNSyncLoader",
|
||||
Description: "Load plugins from CDN synchronously",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaPluginsPlatformSquad,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
@@ -962,4 +962,8 @@ const (
|
||||
// FlagGrafanaconThemes
|
||||
// Enables the temporary themes for GrafanaCon
|
||||
FlagGrafanaconThemes = "grafanaconThemes"
|
||||
|
||||
// FlagPluginsCDNSyncLoader
|
||||
// Load plugins from CDN synchronously
|
||||
FlagPluginsCDNSyncLoader = "pluginsCDNSyncLoader"
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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{})
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<ComponentType<{ children: ReactNode }>> = [];
|
||||
|
||||
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<AppWrapperProps, AppWrapperState> {
|
||||
routes: ready && this.renderRoutes(),
|
||||
pageBanners,
|
||||
bodyRenderHooks,
|
||||
providers: enterpriseProviders,
|
||||
};
|
||||
|
||||
const MaybeTimeRangeProvider = config.featureToggles.timeRangeProvider ? TimeRangeProvider : Fragment;
|
||||
|
||||
@@ -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!)) {
|
||||
|
||||
+16
-14
@@ -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 (
|
||||
<Stack direction="column">
|
||||
<Text>
|
||||
<Trans i18nKey="dashboard.edit-pane.objects.multi-select.selection-number">No. of objects selected: </Trans>
|
||||
{this.items?.length}
|
||||
<Trans
|
||||
i18nKey="dashboard.edit-pane.objects.multi-select.selection-number"
|
||||
values={{ length: this._elements.length }}
|
||||
>
|
||||
No. of objects selected: {{ length }}
|
||||
</Trans>
|
||||
</Text>
|
||||
<Stack direction="row">
|
||||
<Button size="sm" variant="secondary" icon="copy" />
|
||||
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete} icon="trash-alt" />
|
||||
<Button size="sm" variant="destructive" fill="outline" onClick={() => this.onDelete()} icon="trash-alt" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
public onDelete() {
|
||||
this._elements.forEach((item) => item.onDelete());
|
||||
}
|
||||
}
|
||||
+19
-26
@@ -1,9 +1,9 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { SceneObject, VizPanel } from '@grafana/scenes';
|
||||
import { VizPanel } from '@grafana/scenes';
|
||||
import { Button, Stack, Text } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
|
||||
import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement';
|
||||
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
|
||||
@@ -11,42 +11,35 @@ import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
|
||||
export class MultiSelectedVizPanelsEditableElement implements MultiSelectedEditableDashboardElement {
|
||||
public readonly isMultiSelectedEditableDashboardElement = true;
|
||||
public readonly typeName = 'Panels';
|
||||
public readonly key: string;
|
||||
|
||||
private items?: VizPanel[];
|
||||
|
||||
constructor(items: SceneObject[]) {
|
||||
this.items = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item instanceof VizPanel) {
|
||||
this.items.push(item);
|
||||
}
|
||||
}
|
||||
constructor(private _panels: VizPanel[]) {
|
||||
this.key = uuidv4();
|
||||
}
|
||||
|
||||
useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
public onDelete = () => {
|
||||
for (const panel of this.items || []) {
|
||||
const layout = dashboardSceneGraph.getLayoutManagerFor(panel);
|
||||
layout.removePanel(panel);
|
||||
}
|
||||
};
|
||||
|
||||
renderActions(): ReactNode {
|
||||
return (
|
||||
<Stack direction="column">
|
||||
<Text>
|
||||
<Trans i18nKey="dashboard.edit-pane.panels.multi-select.selection-number">No. of panels selected: </Trans>
|
||||
{this.items?.length}
|
||||
<Trans
|
||||
i18nKey="dashboard.edit-pane.panels.multi-select.selection-number"
|
||||
values={{ length: this._panels.length }}
|
||||
>
|
||||
No. of panels selected: {{ length }}
|
||||
</Trans>
|
||||
</Text>
|
||||
<Stack direction="row">
|
||||
<Button size="sm" variant="secondary" icon="copy" />
|
||||
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete} icon="trash-alt" />
|
||||
<Button size="sm" variant="destructive" fill="outline" onClick={() => this.onDelete()} icon="trash-alt" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
public onDelete() {
|
||||
this._panels.forEach((panel) => {
|
||||
const layout = dashboardSceneGraph.getLayoutManagerFor(panel);
|
||||
layout.removePanel?.(panel);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
|
||||
|
||||
public onDelete = () => {
|
||||
const layout = dashboardSceneGraph.getLayoutManagerFor(this.panel);
|
||||
layout.removePanel(this.panel);
|
||||
layout.removePanel?.(this.panel);
|
||||
};
|
||||
|
||||
public renderActions(): ReactNode {
|
||||
|
||||
+2
-2
@@ -309,7 +309,7 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat
|
||||
export function PanelDataQueriesTabRendered({ model }: SceneComponentProps<PanelDataQueriesTab>) {
|
||||
const { datasource, dsSettings } = model.useState();
|
||||
const { data, queries } = model.queryRunner.useState();
|
||||
const { openDrawer: openQueryLibraryDrawer } = useQueryLibraryContext();
|
||||
const { openDrawer: openQueryLibraryDrawer, queryLibraryEnabled } = useQueryLibraryContext();
|
||||
|
||||
if (!datasource || !dsSettings || !data) {
|
||||
return null;
|
||||
@@ -355,7 +355,7 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps<Panel
|
||||
>
|
||||
Add query
|
||||
</Button>
|
||||
{config.featureToggles.queryLibrary && (
|
||||
{queryLibraryEnabled && (
|
||||
<Button
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
|
||||
@@ -503,7 +503,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
}
|
||||
|
||||
public duplicatePanel(vizPanel: VizPanel) {
|
||||
getLayoutManagerFor(vizPanel).duplicatePanel(vizPanel);
|
||||
getLayoutManagerFor(vizPanel).duplicatePanel?.(vizPanel);
|
||||
}
|
||||
|
||||
public copyPanel(vizPanel: VizPanel) {
|
||||
@@ -537,7 +537,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
}
|
||||
|
||||
public removePanel(panel: VizPanel) {
|
||||
getLayoutManagerFor(panel).removePanel(panel);
|
||||
getLayoutManagerFor(panel).removePanel?.(panel);
|
||||
}
|
||||
|
||||
public unlinkLibraryPanel(panel: VizPanel) {
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { isEqual } from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import {
|
||||
VizPanel,
|
||||
SceneObjectBase,
|
||||
SceneGridLayout,
|
||||
SceneVariableSet,
|
||||
SceneComponentProps,
|
||||
SceneGridItemStateLike,
|
||||
SceneGridItemLike,
|
||||
sceneGraph,
|
||||
@@ -17,18 +13,18 @@ import {
|
||||
CustomVariable,
|
||||
VizPanelState,
|
||||
VariableValueSingle,
|
||||
SceneVariable,
|
||||
SceneVariableDependencyConfigLike,
|
||||
} from '@grafana/scenes';
|
||||
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
|
||||
import { GRID_COLUMN_COUNT } from 'app/core/constants';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
|
||||
import { getCloneKey } from '../../utils/clone';
|
||||
import { getMultiVariableValues, getQueryRunnerFor } from '../../utils/utils';
|
||||
import { getMultiVariableValues } from '../../utils/utils';
|
||||
import { DashboardLayoutItem } from '../types/DashboardLayoutItem';
|
||||
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
|
||||
|
||||
import { getDashboardGridItemOptions } from './DashboardGridItemEditor';
|
||||
import { DashboardGridItemRenderer } from './DashboardGridItemRenderer';
|
||||
import { DashboardGridItemVariableDependencyHandler } from './DashboardGridItemVariableDependencyHandler';
|
||||
|
||||
export interface DashboardGridItemState extends SceneGridItemStateLike {
|
||||
body: VizPanel;
|
||||
@@ -45,11 +41,14 @@ export class DashboardGridItem
|
||||
extends SceneObjectBase<DashboardGridItemState>
|
||||
implements SceneGridItemLike, DashboardLayoutItem
|
||||
{
|
||||
private _prevRepeatValues?: VariableValueSingle[];
|
||||
public static Component = DashboardGridItemRenderer;
|
||||
|
||||
protected _variableDependency = new DashboardGridItemVariableDependencyHandler(this);
|
||||
|
||||
public readonly isDashboardLayoutItem = true;
|
||||
|
||||
private _prevRepeatValues?: VariableValueSingle[];
|
||||
|
||||
public constructor(state: DashboardGridItemState) {
|
||||
super(state);
|
||||
|
||||
@@ -63,21 +62,16 @@ export class DashboardGridItem
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the current repeat item count to calculate the user intended desired itemHeight
|
||||
*/
|
||||
private _handleGridResize(newState: DashboardGridItemState, prevState: DashboardGridItemState) {
|
||||
const itemCount = this.state.repeatedPanels?.length ?? 1;
|
||||
const stateChange: Partial<DashboardGridItemState> = {};
|
||||
|
||||
// Height changed
|
||||
if (newState.height === prevState.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.getRepeatDirection() === 'v') {
|
||||
const itemHeight = Math.ceil(newState.height! / itemCount);
|
||||
stateChange.itemHeight = itemHeight;
|
||||
stateChange.itemHeight = Math.ceil(newState.height! / itemCount);
|
||||
} else {
|
||||
const rowCount = Math.ceil(itemCount / this.getMaxPerRow());
|
||||
stateChange.itemHeight = Math.ceil(newState.height! / rowCount);
|
||||
@@ -88,6 +82,37 @@ export class DashboardGridItem
|
||||
}
|
||||
}
|
||||
|
||||
public getClassName(): string {
|
||||
return this.state.variableName ? 'panel-repeater-grid-item' : '';
|
||||
}
|
||||
|
||||
public getOptions(): OptionsPaneCategoryDescriptor {
|
||||
return getDashboardGridItemOptions(this);
|
||||
}
|
||||
|
||||
public editingStarted() {
|
||||
if (!this.state.variableName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.repeatedPanels?.length ?? 0 > 1) {
|
||||
this.state.body.setState({
|
||||
$variables: this.state.repeatedPanels![0].state.$variables?.clone(),
|
||||
$data: this.state.repeatedPanels![0].state.$data?.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public editingCompleted(withChanges: boolean) {
|
||||
if (withChanges) {
|
||||
this._prevRepeatValues = undefined;
|
||||
}
|
||||
|
||||
if (this.state.variableName && this.state.repeatDirection === 'h' && this.state.width !== GRID_COLUMN_COUNT) {
|
||||
this.setState({ width: GRID_COLUMN_COUNT });
|
||||
}
|
||||
}
|
||||
|
||||
public performRepeat() {
|
||||
if (!this.state.variableName || sceneGraph.hasVariableDependencyInLoadingState(this)) {
|
||||
return;
|
||||
@@ -111,9 +136,6 @@ export class DashboardGridItem
|
||||
const { values, texts } = getMultiVariableValues(variable);
|
||||
|
||||
if (isEqual(this._prevRepeatValues, values)) {
|
||||
// In some cases, like for variables that depend on time range, the panel query runners are waiting for the top level variable to complete
|
||||
// So even when there was no change in the variable value (like in this case) we need to notify the query runners that the variable has completed it's update
|
||||
// this.notifyRepeatedPanelsWaitingForVariables(variable);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -121,7 +143,7 @@ export class DashboardGridItem
|
||||
const repeatedPanels: VizPanel[] = [];
|
||||
|
||||
// when variable has no options (due to error or similar) it will not render any panels at all
|
||||
// adding a placeholder in this case so that there is at least empty panel that can display error
|
||||
// adding a placeholder in this case so that there is at least empty panel that can display error
|
||||
const emptyVariablePlaceholderOption = {
|
||||
values: [''],
|
||||
texts: variable.hasAllValue() ? ['All'] : ['None'],
|
||||
@@ -163,7 +185,6 @@ export class DashboardGridItem
|
||||
|
||||
this.setState(stateChange);
|
||||
|
||||
// In case we updated our height the grid layout needs to be update
|
||||
if (prevHeight !== this.state.height) {
|
||||
const layout = sceneGraph.getLayout(this);
|
||||
if (layout instanceof SceneGridLayout) {
|
||||
@@ -173,7 +194,6 @@ export class DashboardGridItem
|
||||
|
||||
this._prevRepeatValues = values;
|
||||
|
||||
// Used from dashboard url sync
|
||||
this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true);
|
||||
}
|
||||
|
||||
@@ -191,167 +211,23 @@ export class DashboardGridItem
|
||||
this.setState(stateUpdate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns options for panel edit
|
||||
*/
|
||||
public getOptions(): OptionsPaneCategoryDescriptor {
|
||||
return getDashboardGridItemOptions(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logic to prep panel for panel edit
|
||||
*/
|
||||
public editingStarted() {
|
||||
if (!this.state.variableName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.repeatedPanels?.length ?? 0 > 1) {
|
||||
this.state.body.setState({
|
||||
$variables: this.state.repeatedPanels![0].state.$variables?.clone(),
|
||||
$data: this.state.repeatedPanels![0].state.$data?.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Going back to dashboards logic
|
||||
* withChanges true if there where changes made while in panel edit
|
||||
*/
|
||||
public editingCompleted(withChanges: boolean) {
|
||||
if (withChanges) {
|
||||
this._prevRepeatValues = undefined;
|
||||
}
|
||||
|
||||
if (this.state.variableName && this.state.repeatDirection === 'h' && this.state.width !== GRID_COLUMN_COUNT) {
|
||||
this.setState({ width: GRID_COLUMN_COUNT });
|
||||
}
|
||||
}
|
||||
|
||||
public notifyRepeatedPanelsWaitingForVariables(variable: SceneVariable) {
|
||||
for (const panel of this.state.repeatedPanels ?? []) {
|
||||
const queryRunner = getQueryRunnerFor(panel);
|
||||
if (queryRunner) {
|
||||
queryRunner.variableDependency?.variableUpdateCompleted(variable, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getMaxPerRow(): number {
|
||||
return this.state.maxPerRow ?? 4;
|
||||
}
|
||||
|
||||
public setMaxPerRow(maxPerRow: number | undefined) {
|
||||
this.setState({ maxPerRow });
|
||||
}
|
||||
|
||||
public getRepeatDirection(): RepeatDirection {
|
||||
return this.state.repeatDirection === 'v' ? 'v' : 'h';
|
||||
}
|
||||
|
||||
public getClassName() {
|
||||
return this.state.variableName ? 'panel-repeater-grid-item' : '';
|
||||
public setRepeatDirection(repeatDirection: RepeatDirection) {
|
||||
this.setState({ repeatDirection });
|
||||
}
|
||||
|
||||
public isRepeated() {
|
||||
public isRepeated(): boolean {
|
||||
return this.state.variableName !== undefined;
|
||||
}
|
||||
|
||||
public static Component = ({ model }: SceneComponentProps<DashboardGridItem>) => {
|
||||
const { repeatedPanels, itemHeight, variableName, body } = model.useState();
|
||||
const itemCount = repeatedPanels?.length ?? 0;
|
||||
const layoutStyle = useLayoutStyle(model.getRepeatDirection(), itemCount, model.getMaxPerRow(), itemHeight ?? 10);
|
||||
|
||||
if (!variableName) {
|
||||
if (body instanceof VizPanel) {
|
||||
return <body.Component model={body} key={body.state.key} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (!repeatedPanels) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={layoutStyle}>
|
||||
{repeatedPanels.map((panel) => (
|
||||
<div className={itemStyle} key={panel.state.key}>
|
||||
<panel.Component model={panel} key={panel.state.key} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export class DashboardGridItemVariableDependencyHandler implements SceneVariableDependencyConfigLike {
|
||||
constructor(private _gridItem: DashboardGridItem) {}
|
||||
|
||||
getNames(): Set<string> {
|
||||
if (this._gridItem.state.variableName) {
|
||||
return new Set([this._gridItem.state.variableName]);
|
||||
}
|
||||
|
||||
return new Set();
|
||||
}
|
||||
|
||||
hasDependencyOn(name: string): boolean {
|
||||
return this._gridItem.state.variableName === name;
|
||||
}
|
||||
|
||||
variableUpdateCompleted(variable: SceneVariable, hasChanged: boolean): void {
|
||||
if (this._gridItem.state.variableName === variable.state.name) {
|
||||
/**
|
||||
* We do not really care if the variable has changed or not as we do an equality check in performRepeat
|
||||
* And this function needs to be called even when variable valued id not change as performRepeat calls
|
||||
* notifyRepeatedPanelsWaitingForVariables which is needed to notify panels waiting for variable to complete (even when the value did not change)
|
||||
* This is for scenarios where the variable used for repeating is depending on time range.
|
||||
*/
|
||||
this._gridItem.performRepeat();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function useLayoutStyle(direction: RepeatDirection, itemCount: number, maxPerRow: number, itemHeight: number) {
|
||||
return useMemo(() => {
|
||||
const theme = config.theme2;
|
||||
|
||||
// In mobile responsive layout we have to calculate the absolute height
|
||||
const mobileHeight = itemHeight * GRID_CELL_HEIGHT * itemCount + (itemCount - 1) * GRID_CELL_VMARGIN;
|
||||
|
||||
if (direction === 'h') {
|
||||
const rowCount = Math.ceil(itemCount / maxPerRow);
|
||||
const columnCount = Math.min(itemCount, maxPerRow);
|
||||
|
||||
return css({
|
||||
display: 'grid',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${rowCount}, 1fr)`,
|
||||
gridColumnGap: theme.spacing(1),
|
||||
gridRowGap: theme.spacing(1),
|
||||
|
||||
[theme.breakpoints.down('md')]: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: mobileHeight,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Vertical is a bit simpler
|
||||
return css({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
[theme.breakpoints.down('md')]: {
|
||||
height: mobileHeight,
|
||||
},
|
||||
});
|
||||
}, [direction, itemCount, maxPerRow, itemHeight]);
|
||||
}
|
||||
|
||||
const itemStyle = css({
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
position: 'relative',
|
||||
});
|
||||
+7
-4
@@ -7,7 +7,7 @@ import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSel
|
||||
|
||||
import { DashboardGridItem } from './DashboardGridItem';
|
||||
|
||||
export function getDashboardGridItemOptions(gridItem: DashboardGridItem) {
|
||||
export function getDashboardGridItemOptions(gridItem: DashboardGridItem): OptionsPaneCategoryDescriptor {
|
||||
const category = new OptionsPaneCategoryDescriptor({
|
||||
title: t('dashboard.default-layout.item-options.repeat.title', 'Repeat options'),
|
||||
id: 'Repeat options',
|
||||
@@ -66,20 +66,23 @@ function RepeatDirectionOption({ gridItem }: OptionComponentProps) {
|
||||
<RadioButtonGroup
|
||||
options={directionOptions}
|
||||
value={repeatDirection ?? 'h'}
|
||||
onChange={(value) => gridItem.setState({ repeatDirection: value })}
|
||||
onChange={(value) => gridItem.setRepeatDirection(value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MaxPerRowOption({ gridItem }: OptionComponentProps) {
|
||||
const { maxPerRow } = gridItem.useState();
|
||||
const maxPerRowOptions = [2, 3, 4, 6, 8, 12].map((value) => ({ label: value.toString(), value }));
|
||||
const maxPerRowOptions: Array<SelectableValue<number>> = [2, 3, 4, 6, 8, 12].map((value) => ({
|
||||
label: value.toString(),
|
||||
value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={maxPerRowOptions}
|
||||
value={maxPerRow ?? 4}
|
||||
onChange={(value) => gridItem.setState({ maxPerRow: value.value })}
|
||||
onChange={(value) => gridItem.setMaxPerRow(value.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { SceneComponentProps, VizPanel } from '@grafana/scenes';
|
||||
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
|
||||
|
||||
import { DashboardGridItem, RepeatDirection } from './DashboardGridItem';
|
||||
|
||||
export function DashboardGridItemRenderer({ model }: SceneComponentProps<DashboardGridItem>) {
|
||||
const { repeatedPanels, itemHeight, variableName, body } = model.useState();
|
||||
const itemCount = repeatedPanels?.length ?? 0;
|
||||
const layoutStyle = useLayoutStyle(model.getRepeatDirection(), itemCount, model.getMaxPerRow(), itemHeight ?? 10);
|
||||
|
||||
if (!variableName) {
|
||||
if (body instanceof VizPanel) {
|
||||
return <body.Component model={body} key={body.state.key} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (!repeatedPanels) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={layoutStyle}>
|
||||
{repeatedPanels.map((panel) => (
|
||||
<div className={itemStyle} key={panel.state.key}>
|
||||
<panel.Component model={panel} key={panel.state.key} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useLayoutStyle(direction: RepeatDirection, itemCount: number, maxPerRow: number, itemHeight: number) {
|
||||
return useMemo(() => {
|
||||
const theme = config.theme2;
|
||||
|
||||
// In mobile responsive layout we have to calculate the absolute height
|
||||
const mobileHeight = itemHeight * GRID_CELL_HEIGHT * itemCount + (itemCount - 1) * GRID_CELL_VMARGIN;
|
||||
|
||||
if (direction === 'h') {
|
||||
const rowCount = Math.ceil(itemCount / maxPerRow);
|
||||
const columnCount = Math.min(itemCount, maxPerRow);
|
||||
|
||||
return css({
|
||||
display: 'grid',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${rowCount}, 1fr)`,
|
||||
gridColumnGap: theme.spacing(1),
|
||||
gridRowGap: theme.spacing(1),
|
||||
|
||||
[theme.breakpoints.down('md')]: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: mobileHeight,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Vertical is a bit simpler
|
||||
return css({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
[theme.breakpoints.down('md')]: {
|
||||
height: mobileHeight,
|
||||
},
|
||||
});
|
||||
}, [direction, itemCount, maxPerRow, itemHeight]);
|
||||
}
|
||||
|
||||
const itemStyle = css({
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
position: 'relative',
|
||||
});
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { SceneVariable, SceneVariableDependencyConfigLike } from '@grafana/scenes';
|
||||
|
||||
import { DashboardGridItem } from './DashboardGridItem';
|
||||
|
||||
export class DashboardGridItemVariableDependencyHandler implements SceneVariableDependencyConfigLike {
|
||||
constructor(private _gridItem: DashboardGridItem) {}
|
||||
|
||||
public getNames(): Set<string> {
|
||||
if (this._gridItem.state.variableName) {
|
||||
return new Set([this._gridItem.state.variableName]);
|
||||
}
|
||||
|
||||
return new Set();
|
||||
}
|
||||
|
||||
public hasDependencyOn(name: string): boolean {
|
||||
return this._gridItem.state.variableName === name;
|
||||
}
|
||||
|
||||
public variableUpdateCompleted(variable: SceneVariable) {
|
||||
if (this._gridItem.state.variableName === variable.state.name) {
|
||||
/**
|
||||
* We do not really care if the variable has changed or not as we do an equality check in performRepeat
|
||||
* And this function needs to be called even when variable valued id not change as performRepeat calls
|
||||
* notifyRepeatedPanelsWaitingForVariables which is needed to notify panels waiting for variable to complete (even when the value did not change)
|
||||
* This is for scenarios where the variable used for repeating is depending on time range.
|
||||
*/
|
||||
this._gridItem.performRepeat();
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
-131
@@ -36,13 +36,12 @@ interface DefaultGridLayoutManagerState extends SceneObjectState {
|
||||
grid: SceneGridLayout;
|
||||
}
|
||||
|
||||
/**
|
||||
* State manager for the default grid layout
|
||||
*/
|
||||
export class DefaultGridLayoutManager
|
||||
extends SceneObjectBase<DefaultGridLayoutManagerState>
|
||||
implements DashboardLayoutManager
|
||||
{
|
||||
public static Component = DefaultGridLayoutManagerRenderer;
|
||||
|
||||
public readonly isDashboardLayoutManager = true;
|
||||
|
||||
public static readonly descriptor = {
|
||||
@@ -58,23 +57,7 @@ export class DefaultGridLayoutManager
|
||||
|
||||
public readonly descriptor = DefaultGridLayoutManager.descriptor;
|
||||
|
||||
public editModeChanged(isEditing: boolean): void {
|
||||
const updateResizeAndDragging = () => {
|
||||
this.state.grid.setState({ isDraggable: isEditing, isResizable: isEditing });
|
||||
forceRenderChildren(this.state.grid, true);
|
||||
};
|
||||
|
||||
if (config.featureToggles.dashboardNewLayouts) {
|
||||
// We do this in a timeout to wait a bit with enabling dragging as dragging enables grid animations
|
||||
// if we show the edit pane without animations it opens much faster and feels more responsive
|
||||
setTimeout(updateResizeAndDragging, 10);
|
||||
return;
|
||||
}
|
||||
|
||||
updateResizeAndDragging();
|
||||
}
|
||||
|
||||
public addPanel(vizPanel: VizPanel): void {
|
||||
public addPanel(vizPanel: VizPanel) {
|
||||
const panelId = dashboardSceneGraph.getNextPanelId(this);
|
||||
|
||||
vizPanel.setState({ key: getVizPanelKeyForPanelId(panelId) });
|
||||
@@ -94,61 +77,7 @@ export class DefaultGridLayoutManager
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new empty row
|
||||
*/
|
||||
public addNewRow(): SceneGridRow {
|
||||
const id = dashboardSceneGraph.getNextPanelId(this);
|
||||
|
||||
const row = new SceneGridRow({
|
||||
key: getVizPanelKeyForPanelId(id),
|
||||
title: 'Row title',
|
||||
actions: new RowActions({}),
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const sceneGridLayout = this.state.grid;
|
||||
|
||||
// find all panels until the first row and put them into the newly created row. If there are no other rows,
|
||||
// add all panels to the row. If there are no panels just create an empty row
|
||||
const indexTillNextRow = sceneGridLayout.state.children.findIndex((child) => child instanceof SceneGridRow);
|
||||
const rowChildren = sceneGridLayout.state.children
|
||||
.splice(0, indexTillNextRow === -1 ? sceneGridLayout.state.children.length : indexTillNextRow)
|
||||
.map((child) => child.clone());
|
||||
|
||||
if (rowChildren) {
|
||||
row.setState({ children: rowChildren });
|
||||
}
|
||||
|
||||
sceneGridLayout.setState({ children: [row, ...sceneGridLayout.state.children] });
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a row
|
||||
* @param row
|
||||
* @param removePanels
|
||||
*/
|
||||
public removeRow(row: SceneGridRow, removePanels = false) {
|
||||
const sceneGridLayout = this.state.grid;
|
||||
|
||||
const children = sceneGridLayout.state.children.filter((child) => child.state.key !== row.state.key);
|
||||
|
||||
if (!removePanels) {
|
||||
const rowChildren = row.state.children.map((child) => child.clone());
|
||||
const indexOfRow = sceneGridLayout.state.children.findIndex((child) => child.state.key === row.state.key);
|
||||
|
||||
children.splice(indexOfRow, 0, ...rowChildren);
|
||||
}
|
||||
|
||||
sceneGridLayout.setState({ children });
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a panel
|
||||
*/
|
||||
public removePanel(panel: VizPanel): void {
|
||||
public removePanel(panel: VizPanel) {
|
||||
const gridItem = panel.parent!;
|
||||
|
||||
if (!(gridItem instanceof DashboardGridItem)) {
|
||||
@@ -176,7 +105,7 @@ export class DefaultGridLayoutManager
|
||||
});
|
||||
}
|
||||
|
||||
public duplicatePanel(vizPanel: VizPanel): void {
|
||||
public duplicatePanel(vizPanel: VizPanel) {
|
||||
const gridItem = vizPanel.parent;
|
||||
if (!(gridItem instanceof DashboardGridItem)) {
|
||||
console.error('Trying to duplicate a panel that is not inside a DashboardGridItem');
|
||||
@@ -210,7 +139,7 @@ export class DefaultGridLayoutManager
|
||||
variableName: gridItem.state.variableName,
|
||||
repeatDirection: gridItem.state.repeatDirection,
|
||||
maxPerRow: gridItem.state.maxPerRow,
|
||||
key: `grid-item-${newPanelId}`,
|
||||
key: getGridItemKeyForPanelId(newPanelId),
|
||||
body: new VizPanel({ ...panelState, $data: panelData, key: getVizPanelKeyForPanelId(newPanelId) }),
|
||||
});
|
||||
|
||||
@@ -234,16 +163,12 @@ export class DefaultGridLayoutManager
|
||||
throw new Error('Child is not a DashboardGridItem or SceneGridRow, invalid scene');
|
||||
}
|
||||
|
||||
if (child instanceof DashboardGridItem) {
|
||||
if (child.state.body instanceof VizPanel) {
|
||||
panels.push(child.state.body);
|
||||
}
|
||||
if (child instanceof DashboardGridItem && child.state.body instanceof VizPanel) {
|
||||
panels.push(child.state.body);
|
||||
} else if (child instanceof SceneGridRow) {
|
||||
child.forEachChild((child) => {
|
||||
if (child instanceof DashboardGridItem) {
|
||||
if (child.state.body instanceof VizPanel) {
|
||||
panels.push(child.state.body);
|
||||
}
|
||||
if (child instanceof DashboardGridItem && child.state.body instanceof VizPanel) {
|
||||
panels.push(child.state.body);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -252,29 +177,51 @@ export class DefaultGridLayoutManager
|
||||
return panels;
|
||||
}
|
||||
|
||||
public collapseAllRows(): void {
|
||||
this.state.grid.state.children.forEach((child) => {
|
||||
if (!(child instanceof SceneGridRow)) {
|
||||
return;
|
||||
}
|
||||
if (!child.state.isCollapsed) {
|
||||
this.state.grid.toggleRow(child);
|
||||
}
|
||||
public addNewRow(): SceneGridRow {
|
||||
const id = dashboardSceneGraph.getNextPanelId(this);
|
||||
|
||||
const row = new SceneGridRow({
|
||||
key: getVizPanelKeyForPanelId(id),
|
||||
title: 'Row title',
|
||||
actions: new RowActions({}),
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const sceneGridLayout = this.state.grid;
|
||||
|
||||
// find all panels until the first row and put them into the newly created row. If there are no other rows,
|
||||
// add all panels to the row. If there are no panels just create an empty row
|
||||
const indexTillNextRow = sceneGridLayout.state.children.findIndex((child) => child instanceof SceneGridRow);
|
||||
const rowChildren = sceneGridLayout.state.children
|
||||
.splice(0, indexTillNextRow === -1 ? sceneGridLayout.state.children.length : indexTillNextRow)
|
||||
.map((child) => child.clone());
|
||||
|
||||
if (rowChildren) {
|
||||
row.setState({ children: rowChildren });
|
||||
}
|
||||
|
||||
sceneGridLayout.setState({ children: [row, ...sceneGridLayout.state.children] });
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
public expandAllRows(): void {
|
||||
this.state.grid.state.children.forEach((child) => {
|
||||
if (!(child instanceof SceneGridRow)) {
|
||||
return;
|
||||
}
|
||||
if (child.state.isCollapsed) {
|
||||
this.state.grid.toggleRow(child);
|
||||
}
|
||||
});
|
||||
public editModeChanged(isEditing: boolean) {
|
||||
const updateResizeAndDragging = () => {
|
||||
this.state.grid.setState({ isDraggable: isEditing, isResizable: isEditing });
|
||||
forceRenderChildren(this.state.grid, true);
|
||||
};
|
||||
|
||||
if (config.featureToggles.dashboardNewLayouts) {
|
||||
// We do this in a timeout to wait a bit with enabling dragging as dragging enables grid animations
|
||||
// if we show the edit pane without animations it opens much faster and feels more responsive
|
||||
setTimeout(updateResizeAndDragging, 10);
|
||||
return;
|
||||
}
|
||||
|
||||
updateResizeAndDragging();
|
||||
}
|
||||
|
||||
public activateRepeaters(): void {
|
||||
public activateRepeaters() {
|
||||
this.state.grid.forEachChild((child) => {
|
||||
if (child instanceof DashboardGridItem && !child.isActive) {
|
||||
child.activate();
|
||||
@@ -319,6 +266,7 @@ export class DefaultGridLayoutManager
|
||||
});
|
||||
|
||||
childrenAcc.children.push(gridItem);
|
||||
|
||||
return childrenAcc;
|
||||
}
|
||||
|
||||
@@ -361,6 +309,7 @@ export class DefaultGridLayoutManager
|
||||
}
|
||||
|
||||
childrenAcc.children.push(child.clone());
|
||||
|
||||
return childrenAcc;
|
||||
},
|
||||
{ panelId: 0, children: [] }
|
||||
@@ -369,20 +318,50 @@ export class DefaultGridLayoutManager
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle switching to the manual grid layout from other layouts
|
||||
* @param currentLayout
|
||||
* @returns
|
||||
*/
|
||||
public removeRow(row: SceneGridRow, removePanels = false) {
|
||||
const sceneGridLayout = this.state.grid;
|
||||
|
||||
const children = sceneGridLayout.state.children.filter((child) => child.state.key !== row.state.key);
|
||||
|
||||
if (!removePanels) {
|
||||
const rowChildren = row.state.children.map((child) => child.clone());
|
||||
const indexOfRow = sceneGridLayout.state.children.findIndex((child) => child.state.key === row.state.key);
|
||||
|
||||
children.splice(indexOfRow, 0, ...rowChildren);
|
||||
}
|
||||
|
||||
sceneGridLayout.setState({ children });
|
||||
}
|
||||
|
||||
public collapseAllRows() {
|
||||
this.state.grid.state.children.forEach((child) => {
|
||||
if (!(child instanceof SceneGridRow)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!child.state.isCollapsed) {
|
||||
this.state.grid.toggleRow(child);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public expandAllRows() {
|
||||
this.state.grid.state.children.forEach((child) => {
|
||||
if (!(child instanceof SceneGridRow)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (child.state.isCollapsed) {
|
||||
this.state.grid.toggleRow(child);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static createFromLayout(currentLayout: DashboardLayoutManager): DefaultGridLayoutManager {
|
||||
const panels = currentLayout.getVizPanels();
|
||||
return DefaultGridLayoutManager.fromVizPanels(panels);
|
||||
}
|
||||
|
||||
/**
|
||||
* For simple test grids
|
||||
* @param panels
|
||||
*/
|
||||
public static fromVizPanels(panels: VizPanel[] = []): DefaultGridLayoutManager {
|
||||
const children: DashboardGridItem[] = [];
|
||||
const panelHeight = 10;
|
||||
@@ -395,7 +374,7 @@ export class DefaultGridLayoutManager
|
||||
|
||||
children.push(
|
||||
new DashboardGridItem({
|
||||
key: `griditem-${getPanelIdForVizPanel(panel)}`,
|
||||
key: getGridItemKeyForPanelId(getPanelIdForVizPanel(panel)),
|
||||
x: currentX,
|
||||
y: currentY,
|
||||
width: panelWidth,
|
||||
@@ -421,13 +400,6 @@ export class DefaultGridLayoutManager
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Useful for preserving items positioning when switching layouts
|
||||
* @param gridItems
|
||||
* @param isDraggable
|
||||
* @param isResizable
|
||||
* @returns
|
||||
*/
|
||||
public static fromGridItems(
|
||||
gridItems: SceneGridItemLike[],
|
||||
isDraggable?: boolean,
|
||||
@@ -448,18 +420,18 @@ export class DefaultGridLayoutManager
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static Component = ({ model }: SceneComponentProps<DefaultGridLayoutManager>) => {
|
||||
const { children } = useSceneObjectState(model.state.grid, { shouldActivateOrKeepAlive: true });
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
function DefaultGridLayoutManagerRenderer({ model }: SceneComponentProps<DefaultGridLayoutManager>) {
|
||||
const { children } = useSceneObjectState(model.state.grid, { shouldActivateOrKeepAlive: true });
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
|
||||
// If we are top level layout and have no children, show empty state
|
||||
if (model.parent === dashboard && children.length === 0) {
|
||||
return (
|
||||
<DashboardEmpty dashboard={dashboard} canCreate={!!dashboard.state.meta.canEdit} key="dashboard-empty-state" />
|
||||
);
|
||||
}
|
||||
// If we are top level layout and have no children, show empty state
|
||||
if (model.parent === dashboard && children.length === 0) {
|
||||
return (
|
||||
<DashboardEmpty dashboard={dashboard} canCreate={!!dashboard.state.meta.canEdit} key="dashboard-empty-state" />
|
||||
);
|
||||
}
|
||||
|
||||
return <model.state.grid.Component model={model.state.grid} />;
|
||||
};
|
||||
return <model.state.grid.Component model={model.state.grid} />;
|
||||
}
|
||||
+6
-6
@@ -60,7 +60,7 @@ describe('RowRepeaterBehavior', () => {
|
||||
expect(row1.state.actions).toBeDefined();
|
||||
|
||||
const gridItemRow1 = row1.state.children[0] as SceneGridItem;
|
||||
expect(gridItemRow1.state.key!).toBe(joinCloneKeys(row1.state.key!, 'griditem-1'));
|
||||
expect(gridItemRow1.state.key!).toBe(joinCloneKeys(row1.state.key!, 'grid-item-1'));
|
||||
expect(gridItemRow1.state.body?.state.key).toBe(joinCloneKeys(gridItemRow1.state.key!, 'canvas-1'));
|
||||
|
||||
const row2 = grid.state.children[2] as SceneGridRow;
|
||||
@@ -70,7 +70,7 @@ describe('RowRepeaterBehavior', () => {
|
||||
expect(row2.state.actions).toBeUndefined();
|
||||
|
||||
const gridItemRow2 = row2.state.children[0] as SceneGridItem;
|
||||
expect(gridItemRow2.state.key!).toBe(joinCloneKeys(row2.state.key!, 'griditem-1'));
|
||||
expect(gridItemRow2.state.key!).toBe(joinCloneKeys(row2.state.key!, 'grid-item-1'));
|
||||
expect(gridItemRow2.state.body?.state.key).toBe(joinCloneKeys(gridItemRow2.state.key!, 'canvas-1'));
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('RowRepeaterBehavior', () => {
|
||||
y: 16,
|
||||
width: 24,
|
||||
height: 5,
|
||||
key: 'griditem-4',
|
||||
key: 'grid-item-4',
|
||||
body: new SceneCanvasText({
|
||||
text: 'new panel',
|
||||
}),
|
||||
@@ -235,7 +235,7 @@ function buildScene(
|
||||
$behaviors: [repeatBehavior],
|
||||
children: [
|
||||
new SceneGridItem({
|
||||
key: 'griditem-1',
|
||||
key: 'grid-item-1',
|
||||
x: 0,
|
||||
y: 11,
|
||||
width: 24,
|
||||
@@ -256,7 +256,7 @@ function buildScene(
|
||||
title: 'Row at the bottom',
|
||||
children: [
|
||||
new SceneGridItem({
|
||||
key: 'griditem-2',
|
||||
key: 'grid-item-2',
|
||||
x: 0,
|
||||
y: 17,
|
||||
body: new SceneCanvasText({
|
||||
@@ -265,7 +265,7 @@ function buildScene(
|
||||
}),
|
||||
}),
|
||||
new SceneGridItem({
|
||||
key: 'griditem-3',
|
||||
key: 'grid-item-3',
|
||||
x: 0,
|
||||
y: 25,
|
||||
body: new SceneCanvasText({
|
||||
|
||||
@@ -31,10 +31,6 @@ interface RowRepeaterBehaviorState extends SceneObjectState {
|
||||
variableName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This behavior will run an effect function when specified variables change
|
||||
*/
|
||||
|
||||
export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorState> {
|
||||
protected _variableDependency = new VariableDependencyConfig(this, {
|
||||
variableNames: [this.state.variableName],
|
||||
|
||||
+10
-130
@@ -1,32 +1,18 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import {
|
||||
SceneComponentProps,
|
||||
sceneGraph,
|
||||
SceneGridRow,
|
||||
SceneObjectBase,
|
||||
SceneObjectState,
|
||||
VizPanel,
|
||||
} from '@grafana/scenes';
|
||||
import { Icon, TextLink, useStyles2 } from '@grafana/ui';
|
||||
import { sceneGraph, SceneGridRow, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
|
||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { ShowConfirmModalEvent } from 'app/types/events';
|
||||
|
||||
import { getDashboardSceneFor, getQueryRunnerFor } from '../../../utils/utils';
|
||||
import { DashboardScene } from '../../DashboardScene';
|
||||
import { DashboardGridItem } from '../DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../DefaultGridLayoutManager';
|
||||
import { RowRepeaterBehavior } from '../RowRepeaterBehavior';
|
||||
|
||||
import { RowOptionsButton } from './RowOptionsButton';
|
||||
import { RowActionsRenderer } from './RowActionsRenderer';
|
||||
|
||||
export interface RowActionsState extends SceneObjectState {}
|
||||
|
||||
export class RowActions extends SceneObjectBase<RowActionsState> {
|
||||
static Component = RowActionsRenderer;
|
||||
|
||||
public getParent(): SceneGridRow {
|
||||
if (!(this.parent instanceof SceneGridRow)) {
|
||||
throw new Error('RowActions must have a SceneGridRow parent');
|
||||
@@ -35,16 +21,12 @@ export class RowActions extends SceneObjectBase<RowActionsState> {
|
||||
return this.parent;
|
||||
}
|
||||
|
||||
public getDashboard(): DashboardScene {
|
||||
return getDashboardSceneFor(this);
|
||||
}
|
||||
|
||||
public removeRow(removePanels?: boolean) {
|
||||
const manager = sceneGraph.getAncestor(this, DefaultGridLayoutManager);
|
||||
manager.removeRow(this.getParent(), removePanels);
|
||||
}
|
||||
|
||||
public onUpdate = (title: string, repeat?: string | null): void => {
|
||||
public onUpdate(title: string, repeat: string | null | undefined) {
|
||||
const row = this.getParent();
|
||||
let repeatBehavior: RowRepeaterBehavior | undefined;
|
||||
|
||||
@@ -61,8 +43,7 @@ export class RowActions extends SceneObjectBase<RowActionsState> {
|
||||
}
|
||||
|
||||
if (repeat) {
|
||||
// Remove repeat behavior if it exists
|
||||
// to retrigger repeat when adding new one
|
||||
// Remove repeat behavior if it exists to re-trigger repeat when adding new one
|
||||
if (repeatBehavior) {
|
||||
repeatBehavior.removeBehavior();
|
||||
}
|
||||
@@ -72,9 +53,9 @@ export class RowActions extends SceneObjectBase<RowActionsState> {
|
||||
} else {
|
||||
repeatBehavior?.removeBehavior();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public onDelete = () => {
|
||||
public onDelete() {
|
||||
appEvents.publish(
|
||||
new ShowConfirmModalEvent({
|
||||
title: t('dashboard.default-layout.row-actions.modal.title', 'Delete row'),
|
||||
@@ -88,106 +69,5 @@ export class RowActions extends SceneObjectBase<RowActionsState> {
|
||||
onAltAction: () => this.removeRow(),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
public getWarning = () => {
|
||||
const row = this.getParent();
|
||||
const gridItems = row.state.children;
|
||||
|
||||
const isAnyPanelUsingDashboardDS = gridItems.some((gridItem) => {
|
||||
if (!(gridItem instanceof DashboardGridItem)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const vizPanel = gridItem.state.body;
|
||||
if (vizPanel instanceof VizPanel) {
|
||||
const runner = getQueryRunnerFor(vizPanel);
|
||||
return (
|
||||
runner?.state.datasource?.uid === SHARED_DASHBOARD_QUERY ||
|
||||
(runner?.state.datasource?.uid === MIXED_DATASOURCE_NAME &&
|
||||
runner?.state.queries.some((query) => query.datasource?.uid === SHARED_DASHBOARD_QUERY))
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (isAnyPanelUsingDashboardDS) {
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
<Trans i18nKey="dashboard.default-layout.row-actions.repeat.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.
|
||||
</Trans>
|
||||
</p>
|
||||
<TextLink
|
||||
external
|
||||
href={
|
||||
'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows'
|
||||
}
|
||||
>
|
||||
<Trans i18nKey="dashboard.default-layout.row-actions.repeat.warning.learn-more">Learn more</Trans>
|
||||
</TextLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
static Component = ({ model }: SceneComponentProps<RowActions>) => {
|
||||
const dashboard = model.getDashboard();
|
||||
const row = model.getParent();
|
||||
const { title } = row.useState();
|
||||
const { meta, isEditing } = dashboard.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const behaviour = row.state.$behaviors?.find((b) => b instanceof RowRepeaterBehavior);
|
||||
|
||||
return (
|
||||
<>
|
||||
{meta.canEdit && isEditing && (
|
||||
<>
|
||||
<div className={styles.rowActions}>
|
||||
<RowOptionsButton
|
||||
title={title}
|
||||
repeat={behaviour instanceof RowRepeaterBehavior ? behaviour.state.variableName : undefined}
|
||||
parent={dashboard}
|
||||
onUpdate={model.onUpdate}
|
||||
warning={model.getWarning()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={model.onDelete}
|
||||
aria-label={t('dashboard.default-layout.row-actions.delete', 'Delete row')}
|
||||
>
|
||||
<Icon name="trash-alt" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
rowActions: css({
|
||||
color: theme.colors.text.secondary,
|
||||
lineHeight: '27px',
|
||||
|
||||
button: {
|
||||
color: theme.colors.text.secondary,
|
||||
paddingLeft: theme.spacing(2),
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
|
||||
'&:hover': {
|
||||
color: theme.colors.text.maxContrast,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { SceneComponentProps, VizPanel } from '@grafana/scenes';
|
||||
import { Icon, useStyles2 } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
|
||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
|
||||
import { getDashboardSceneFor, getQueryRunnerFor } from '../../../utils/utils';
|
||||
import { DashboardGridItem } from '../DashboardGridItem';
|
||||
import { RowRepeaterBehavior } from '../RowRepeaterBehavior';
|
||||
|
||||
import { RowActions } from './RowActions';
|
||||
import { RowOptionsButton } from './RowOptionsButton';
|
||||
|
||||
export function RowActionsRenderer({ model }: SceneComponentProps<RowActions>) {
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
const row = model.getParent();
|
||||
const { title, children } = row.useState();
|
||||
const { meta, isEditing } = dashboard.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const isUsingDashboardDS = useMemo(
|
||||
() =>
|
||||
children.some((gridItem) => {
|
||||
if (!(gridItem instanceof DashboardGridItem)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (gridItem.state.body instanceof VizPanel) {
|
||||
const runner = getQueryRunnerFor(gridItem.state.body);
|
||||
return (
|
||||
runner?.state.datasource?.uid === SHARED_DASHBOARD_QUERY ||
|
||||
(runner?.state.datasource?.uid === MIXED_DATASOURCE_NAME &&
|
||||
runner?.state.queries.some((query) => query.datasource?.uid === SHARED_DASHBOARD_QUERY))
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
[children]
|
||||
);
|
||||
|
||||
const behaviour = row.state.$behaviors?.find((b) => b instanceof RowRepeaterBehavior);
|
||||
|
||||
return (
|
||||
<>
|
||||
{meta.canEdit && isEditing && (
|
||||
<>
|
||||
<div className={styles.rowActions}>
|
||||
<RowOptionsButton
|
||||
title={title}
|
||||
repeat={behaviour instanceof RowRepeaterBehavior ? behaviour.state.variableName : undefined}
|
||||
parent={dashboard}
|
||||
onUpdate={(title, repeat) => model.onUpdate(title, repeat)}
|
||||
isUsingDashboardDS={isUsingDashboardDS}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => model.onDelete()}
|
||||
aria-label={t('dashboard.default-layout.row-actions.delete', 'Delete row')}
|
||||
>
|
||||
<Icon name="trash-alt" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
rowActions: css({
|
||||
color: theme.colors.text.secondary,
|
||||
lineHeight: '27px',
|
||||
|
||||
button: {
|
||||
color: theme.colors.text.secondary,
|
||||
paddingLeft: theme.spacing(2),
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
|
||||
'&:hover': {
|
||||
color: theme.colors.text.maxContrast,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
+7
-11
@@ -1,5 +1,3 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { SceneObject } from '@grafana/scenes';
|
||||
import { Icon, ModalsController } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
@@ -12,15 +10,10 @@ export interface RowOptionsButtonProps {
|
||||
repeat?: string;
|
||||
parent: SceneObject;
|
||||
onUpdate: OnRowOptionsUpdate;
|
||||
warning?: React.ReactNode;
|
||||
isUsingDashboardDS: boolean;
|
||||
}
|
||||
|
||||
export const RowOptionsButton = ({ repeat, title, parent, onUpdate, warning }: RowOptionsButtonProps) => {
|
||||
const onUpdateChange = (hideModal: () => void) => (title: string, repeat?: string | null) => {
|
||||
onUpdate(title, repeat);
|
||||
hideModal();
|
||||
};
|
||||
|
||||
export const RowOptionsButton = ({ repeat, title, parent, onUpdate, isUsingDashboardDS }: RowOptionsButtonProps) => {
|
||||
return (
|
||||
<ModalsController>
|
||||
{({ showModal, hideModal }) => {
|
||||
@@ -35,8 +28,11 @@ export const RowOptionsButton = ({ repeat, title, parent, onUpdate, warning }: R
|
||||
repeat,
|
||||
parent,
|
||||
onDismiss: hideModal,
|
||||
onUpdate: onUpdateChange(hideModal),
|
||||
warning,
|
||||
onUpdate: (title: string, repeat?: string | null) => {
|
||||
onUpdate(title, repeat);
|
||||
hideModal();
|
||||
},
|
||||
isUsingDashboardDS,
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
+10
-2
@@ -10,6 +10,7 @@ import { RowOptionsForm } from './RowOptionsForm';
|
||||
jest.mock('app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect', () => ({
|
||||
RepeatRowSelect2: () => <div />,
|
||||
}));
|
||||
|
||||
describe('DashboardRow', () => {
|
||||
const scene = new DashboardScene({
|
||||
title: 'hello',
|
||||
@@ -28,7 +29,7 @@ describe('DashboardRow', () => {
|
||||
title=""
|
||||
onCancel={jest.fn()}
|
||||
onUpdate={jest.fn()}
|
||||
warning="a warning message"
|
||||
isUsingDashboardDS={true}
|
||||
/>
|
||||
</TestProvider>
|
||||
);
|
||||
@@ -40,7 +41,14 @@ describe('DashboardRow', () => {
|
||||
it('Should not show warning component when does not have warningMessage prop', () => {
|
||||
render(
|
||||
<TestProvider>
|
||||
<RowOptionsForm repeat={'3'} sceneContext={scene} title="" onCancel={jest.fn()} onUpdate={jest.fn()} />
|
||||
<RowOptionsForm
|
||||
repeat={'3'}
|
||||
sceneContext={scene}
|
||||
title=""
|
||||
onCancel={jest.fn()}
|
||||
onUpdate={jest.fn()}
|
||||
isUsingDashboardDS={false}
|
||||
/>
|
||||
</TestProvider>
|
||||
);
|
||||
expect(
|
||||
|
||||
+23
-10
@@ -1,12 +1,12 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { SceneObject } from '@grafana/scenes';
|
||||
import { Button, Field, Modal, Input, Alert } from '@grafana/ui';
|
||||
import { Button, Field, Modal, Input, Alert, TextLink } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect';
|
||||
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
|
||||
|
||||
export type OnRowOptionsUpdate = (title: string, repeat?: string | null) => void;
|
||||
|
||||
@@ -16,17 +16,15 @@ export interface Props {
|
||||
sceneContext: SceneObject;
|
||||
onUpdate: OnRowOptionsUpdate;
|
||||
onCancel: () => void;
|
||||
warning?: React.ReactNode;
|
||||
isUsingDashboardDS: boolean;
|
||||
}
|
||||
|
||||
export const RowOptionsForm = ({ repeat, title, sceneContext, warning, onUpdate, onCancel }: Props) => {
|
||||
export const RowOptionsForm = ({ repeat, title, sceneContext, isUsingDashboardDS, onUpdate, onCancel }: Props) => {
|
||||
const [newRepeat, setNewRepeat] = useState<string | undefined>(repeat);
|
||||
const onChangeRepeat = useCallback((name?: string) => setNewRepeat(name), [setNewRepeat]);
|
||||
|
||||
const { handleSubmit, register } = useForm({
|
||||
defaultValues: {
|
||||
title,
|
||||
},
|
||||
defaultValues: { title },
|
||||
});
|
||||
|
||||
const submit = (formData: { title: string }) => {
|
||||
@@ -38,10 +36,10 @@ export const RowOptionsForm = ({ repeat, title, sceneContext, warning, onUpdate,
|
||||
<Field label={t('dashboard.default-layout.row-options.form.title', 'Title')}>
|
||||
<Input {...register('title')} type="text" />
|
||||
</Field>
|
||||
<Field label={t('dashboard.default-layout.row-options.form.repeat-for', 'Repeat for')}>
|
||||
<Field label={t('dashboard.default-layout.row-options.form.repeat-for.label', 'Repeat for')}>
|
||||
<RepeatRowSelect2 sceneContext={sceneContext} repeat={newRepeat} onChange={onChangeRepeat} />
|
||||
</Field>
|
||||
{warning && (
|
||||
{isUsingDashboardDS && (
|
||||
<Alert
|
||||
data-testid={selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage}
|
||||
severity="warning"
|
||||
@@ -49,7 +47,22 @@ export const RowOptionsForm = ({ repeat, title, sceneContext, warning, onUpdate,
|
||||
topSpacing={3}
|
||||
bottomSpacing={0}
|
||||
>
|
||||
{warning}
|
||||
<div>
|
||||
<p>
|
||||
<Trans i18nKey="dashboard.default-layout.row-options.form.repeat-for.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.
|
||||
</Trans>
|
||||
</p>
|
||||
<TextLink
|
||||
external
|
||||
href={
|
||||
'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows'
|
||||
}
|
||||
>
|
||||
<Trans i18nKey="dashboard.default-layout.row-options.form.repeat-for.learn-more">Learn more</Trans>
|
||||
</TextLink>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
<Modal.ButtonRow>
|
||||
|
||||
+10
-4
@@ -1,5 +1,4 @@
|
||||
import { css } from '@emotion/css';
|
||||
import * as React from 'react';
|
||||
|
||||
import { SceneObject } from '@grafana/scenes';
|
||||
import { Modal, useStyles2 } from '@grafana/ui';
|
||||
@@ -11,12 +10,19 @@ export interface RowOptionsModalProps {
|
||||
title: string;
|
||||
repeat?: string;
|
||||
parent: SceneObject;
|
||||
warning?: React.ReactNode;
|
||||
isUsingDashboardDS: boolean;
|
||||
onDismiss: () => void;
|
||||
onUpdate: OnRowOptionsUpdate;
|
||||
}
|
||||
|
||||
export const RowOptionsModal = ({ repeat, title, parent, onDismiss, onUpdate, warning }: RowOptionsModalProps) => {
|
||||
export const RowOptionsModal = ({
|
||||
repeat,
|
||||
title,
|
||||
parent,
|
||||
onDismiss,
|
||||
onUpdate,
|
||||
isUsingDashboardDS,
|
||||
}: RowOptionsModalProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
@@ -32,7 +38,7 @@ export const RowOptionsModal = ({ repeat, title, parent, onDismiss, onUpdate, wa
|
||||
title={title}
|
||||
onCancel={onDismiss}
|
||||
onUpdate={onUpdate}
|
||||
warning={warning}
|
||||
isUsingDashboardDS={isUsingDashboardDS}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
+8
-69
@@ -1,87 +1,26 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { SceneObjectState, VizPanel, SceneObjectBase, SceneObject, SceneComponentProps } from '@grafana/scenes';
|
||||
import { Switch, useStyles2 } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { SceneObjectState, VizPanel, SceneObjectBase } from '@grafana/scenes';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
|
||||
import { DashboardLayoutItem } from '../types/DashboardLayoutItem';
|
||||
|
||||
import { getOptions } from './ResponsiveGridItemEditor';
|
||||
import { ResponsiveGridItemRenderer } from './ResponsiveGridItemRenderer';
|
||||
|
||||
export interface ResponsiveGridItemState extends SceneObjectState {
|
||||
body: VizPanel;
|
||||
hideWhenNoData?: boolean;
|
||||
}
|
||||
|
||||
export class ResponsiveGridItem extends SceneObjectBase<ResponsiveGridItemState> implements DashboardLayoutItem {
|
||||
public static Component = ResponsiveGridItemRenderer;
|
||||
|
||||
public readonly isDashboardLayoutItem = true;
|
||||
|
||||
public constructor(state: ResponsiveGridItemState) {
|
||||
super(state);
|
||||
this.addActivationHandler(() => this._activationHandler());
|
||||
}
|
||||
|
||||
private _activationHandler() {
|
||||
if (!this.state.hideWhenNoData) {
|
||||
return;
|
||||
}
|
||||
public getOptions(): OptionsPaneCategoryDescriptor {
|
||||
return getOptions(this);
|
||||
}
|
||||
|
||||
public toggleHideWhenNoData() {
|
||||
this.setState({ hideWhenNoData: !this.state.hideWhenNoData });
|
||||
}
|
||||
|
||||
public getOptions?(): OptionsPaneCategoryDescriptor {
|
||||
const model = this;
|
||||
|
||||
const category = new OptionsPaneCategoryDescriptor({
|
||||
title: t('dashboard.responsive-layout.item-options.title', 'Layout options'),
|
||||
id: 'layout-options',
|
||||
isOpenDefault: false,
|
||||
});
|
||||
|
||||
category.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.responsive-layout.item-options.hide-no-data', 'Hide when no data'),
|
||||
render: function renderTransparent() {
|
||||
const { hideWhenNoData } = model.useState();
|
||||
return <Switch value={hideWhenNoData} id="hide-when-no-data" onChange={() => model.toggleHideWhenNoData()} />;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return category;
|
||||
}
|
||||
|
||||
public setBody(body: SceneObject): void {
|
||||
if (body instanceof VizPanel) {
|
||||
this.setState({ body });
|
||||
}
|
||||
}
|
||||
|
||||
public getVizPanel() {
|
||||
return this.state.body;
|
||||
}
|
||||
|
||||
public static Component = ({ model }: SceneComponentProps<ResponsiveGridItem>) => {
|
||||
const { body } = model.useState();
|
||||
const style = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={cx(style.wrapper)}>
|
||||
<body.Component model={body} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
wrapper: css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
}),
|
||||
};
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { Switch } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
|
||||
import { ResponsiveGridItem } from './ResponsiveGridItem';
|
||||
|
||||
export function getOptions(model: ResponsiveGridItem): OptionsPaneCategoryDescriptor {
|
||||
const category = new OptionsPaneCategoryDescriptor({
|
||||
title: t('dashboard.responsive-layout.item-options.title', 'Layout options'),
|
||||
id: 'layout-options',
|
||||
isOpenDefault: false,
|
||||
});
|
||||
|
||||
category.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.responsive-layout.item-options.hide-no-data', 'Hide when no data'),
|
||||
render: () => <GridItemNoDataToggle item={model} />,
|
||||
})
|
||||
);
|
||||
|
||||
return category;
|
||||
}
|
||||
|
||||
function GridItemNoDataToggle({ item }: { item: ResponsiveGridItem }) {
|
||||
const { hideWhenNoData } = item.useState();
|
||||
|
||||
return <Switch value={hideWhenNoData} id="hide-when-no-data" onChange={() => item.toggleHideWhenNoData()} />;
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { SceneComponentProps } from '@grafana/scenes';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { ResponsiveGridItem } from './ResponsiveGridItem';
|
||||
|
||||
export function ResponsiveGridItemRenderer({ model }: SceneComponentProps<ResponsiveGridItem>) {
|
||||
const { body } = model.useState();
|
||||
const style = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={cx(style.wrapper)}>
|
||||
<body.Component model={body} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles() {
|
||||
return {
|
||||
wrapper: css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
}),
|
||||
};
|
||||
}
|
||||
+29
-105
@@ -1,6 +1,4 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { SceneComponentProps, SceneCSSGridLayout, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
|
||||
import { Select } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
|
||||
@@ -10,6 +8,7 @@ import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
|
||||
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
|
||||
|
||||
import { ResponsiveGridItem } from './ResponsiveGridItem';
|
||||
import { getEditOptions } from './ResponsiveGridLayoutManagerEditor';
|
||||
|
||||
interface ResponsiveGridLayoutManagerState extends SceneObjectState {
|
||||
layout: SceneCSSGridLayout;
|
||||
@@ -19,6 +18,8 @@ export class ResponsiveGridLayoutManager
|
||||
extends SceneObjectBase<ResponsiveGridLayoutManagerState>
|
||||
implements DashboardLayoutManager
|
||||
{
|
||||
public static Component = ResponsiveGridLayoutManagerRenderer;
|
||||
|
||||
public readonly isDashboardLayoutManager = true;
|
||||
|
||||
public static readonly descriptor = {
|
||||
@@ -37,13 +38,11 @@ export class ResponsiveGridLayoutManager
|
||||
public constructor(state: ResponsiveGridLayoutManagerState) {
|
||||
super(state);
|
||||
|
||||
//@ts-ignore
|
||||
// @ts-ignore
|
||||
this.state.layout.getDragClassCancel = () => 'drag-cancel';
|
||||
}
|
||||
|
||||
public editModeChanged(isEditing: boolean): void {}
|
||||
|
||||
public addPanel(vizPanel: VizPanel): void {
|
||||
public addPanel(vizPanel: VizPanel) {
|
||||
const panelId = dashboardSceneGraph.getNextPanelId(this);
|
||||
|
||||
vizPanel.setState({ key: getVizPanelKeyForPanelId(panelId) });
|
||||
@@ -54,18 +53,12 @@ export class ResponsiveGridLayoutManager
|
||||
});
|
||||
}
|
||||
|
||||
public addNewRow(): void {
|
||||
const rowsLayout = RowsLayoutManager.createFromLayout(this);
|
||||
rowsLayout.addNewRow();
|
||||
getDashboardSceneFor(this).switchLayout(rowsLayout);
|
||||
}
|
||||
|
||||
public removePanel(panel: VizPanel) {
|
||||
const element = panel.parent;
|
||||
this.state.layout.setState({ children: this.state.layout.state.children.filter((child) => child !== element) });
|
||||
}
|
||||
|
||||
public duplicatePanel(panel: VizPanel): void {
|
||||
public duplicatePanel(panel: VizPanel) {
|
||||
const gridItem = panel.parent;
|
||||
if (!(gridItem instanceof ResponsiveGridItem)) {
|
||||
console.error('Trying to duplicate a panel that is not inside a DashboardGridItem');
|
||||
@@ -103,11 +96,25 @@ export class ResponsiveGridLayoutManager
|
||||
return panels;
|
||||
}
|
||||
|
||||
public getOptions(): OptionsPaneItemDescriptor[] {
|
||||
return getOptions(this);
|
||||
public addNewRow() {
|
||||
const rowsLayout = RowsLayoutManager.createFromLayout(this);
|
||||
rowsLayout.addNewRow();
|
||||
getDashboardSceneFor(this).switchLayout(rowsLayout);
|
||||
}
|
||||
|
||||
public static createEmpty() {
|
||||
public getOptions(): OptionsPaneItemDescriptor[] {
|
||||
return getEditOptions(this);
|
||||
}
|
||||
|
||||
public changeColumns(columns: string) {
|
||||
this.state.layout.setState({ templateColumns: columns });
|
||||
}
|
||||
|
||||
public changeRows(rows: string) {
|
||||
this.state.layout.setState({ autoRows: rows });
|
||||
}
|
||||
|
||||
public static createEmpty(): ResponsiveGridLayoutManager {
|
||||
return new ResponsiveGridLayoutManager({
|
||||
layout: new SceneCSSGridLayout({
|
||||
children: [],
|
||||
@@ -125,96 +132,13 @@ export class ResponsiveGridLayoutManager
|
||||
children.push(new ResponsiveGridItem({ body: panel.clone() }));
|
||||
}
|
||||
|
||||
return new ResponsiveGridLayoutManager({
|
||||
layout: new SceneCSSGridLayout({
|
||||
children,
|
||||
templateColumns: 'repeat(auto-fit, minmax(400px, auto))',
|
||||
autoRows: 'minmax(300px, auto)',
|
||||
}),
|
||||
});
|
||||
const layoutManager = ResponsiveGridLayoutManager.createEmpty();
|
||||
layoutManager.state.layout.setState({ children });
|
||||
|
||||
return layoutManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Might as well implement this as a no-top function as vs-code very eagerly adds optional functions that throw Method not implemented
|
||||
*/
|
||||
public activateRepeaters(): void {}
|
||||
|
||||
public static Component = ({ model }: SceneComponentProps<ResponsiveGridLayoutManager>) => {
|
||||
return <model.state.layout.Component model={model.state.layout} />;
|
||||
};
|
||||
}
|
||||
|
||||
function getOptions(layoutManager: ResponsiveGridLayoutManager): OptionsPaneItemDescriptor[] {
|
||||
const options: OptionsPaneItemDescriptor[] = [];
|
||||
|
||||
const cssLayout = layoutManager.state.layout;
|
||||
|
||||
const rowOptions: Array<SelectableValue<string>> = [];
|
||||
const sizes = [100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 650];
|
||||
const colOptions: Array<SelectableValue<string>> = [
|
||||
{ label: t('dashboard.responsive-layout.options.one-column', '1 column'), value: `1fr` },
|
||||
{ label: t('dashboard.responsive-layout.options.two-columns', '2 columns'), value: `1fr 1fr` },
|
||||
{ label: t('dashboard.responsive-layout.options.three-columns', '3 columns'), value: `1fr 1fr 1fr` },
|
||||
];
|
||||
|
||||
for (const size of sizes) {
|
||||
colOptions.push({
|
||||
label: t('dashboard.responsive-layout.options.min', 'Min: {{size}}px', { size }),
|
||||
value: `repeat(auto-fit, minmax(${size}px, auto))`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const size of sizes) {
|
||||
rowOptions.push({
|
||||
label: t('dashboard.responsive-layout.options.min', 'Min: {{size}}px', { size }),
|
||||
value: `minmax(${size}px, auto)`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const size of sizes) {
|
||||
rowOptions.push({
|
||||
label: t('dashboard.responsive-layout.options.fixed', 'Fixed: {{size}}px', { size }),
|
||||
value: `${size}px`,
|
||||
});
|
||||
}
|
||||
|
||||
options.push(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.responsive-layout.options.columns', 'Columns'),
|
||||
render: () => {
|
||||
const { templateColumns } = cssLayout.useState();
|
||||
return (
|
||||
<Select
|
||||
options={colOptions}
|
||||
value={String(templateColumns)}
|
||||
onChange={(value) => {
|
||||
cssLayout.setState({ templateColumns: value.value });
|
||||
}}
|
||||
allowCustomValue={true}
|
||||
/>
|
||||
);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
options.push(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.responsive-layout.options.rows', 'Rows'),
|
||||
render: () => {
|
||||
const { autoRows } = cssLayout.useState();
|
||||
return (
|
||||
<Select
|
||||
options={rowOptions}
|
||||
value={String(autoRows)}
|
||||
onChange={(value) => {
|
||||
cssLayout.setState({ autoRows: value.value });
|
||||
}}
|
||||
allowCustomValue={true}
|
||||
/>
|
||||
);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return options;
|
||||
function ResponsiveGridLayoutManagerRenderer({ model }: SceneComponentProps<ResponsiveGridLayoutManager>) {
|
||||
return <model.state.layout.Component model={model.state.layout} />;
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { Select } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
|
||||
import { ResponsiveGridLayoutManager } from './ResponsiveGridLayoutManager';
|
||||
|
||||
const sizes = [100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 650];
|
||||
|
||||
export function getEditOptions(layoutManager: ResponsiveGridLayoutManager): OptionsPaneItemDescriptor[] {
|
||||
const options: OptionsPaneItemDescriptor[] = [];
|
||||
|
||||
options.push(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.responsive-layout.options.columns', 'Columns'),
|
||||
render: () => <GridLayoutColumns layoutManager={layoutManager} />,
|
||||
})
|
||||
);
|
||||
|
||||
options.push(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.responsive-layout.options.rows', 'Rows'),
|
||||
render: () => <GridLayoutRows layoutManager={layoutManager} />,
|
||||
})
|
||||
);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function GridLayoutColumns({ layoutManager }: { layoutManager: ResponsiveGridLayoutManager }) {
|
||||
const { templateColumns } = layoutManager.state.layout.useState();
|
||||
|
||||
const colOptions: Array<SelectableValue<string>> = [
|
||||
{ label: t('dashboard.responsive-layout.options.one-column', '1 column'), value: `1fr` },
|
||||
{ label: t('dashboard.responsive-layout.options.two-columns', '2 columns'), value: `1fr 1fr` },
|
||||
{ label: t('dashboard.responsive-layout.options.three-columns', '3 columns'), value: `1fr 1fr 1fr` },
|
||||
];
|
||||
|
||||
for (const size of sizes) {
|
||||
colOptions.push({
|
||||
label: t('dashboard.responsive-layout.options.min', 'Min: {{size}}px', { size }),
|
||||
value: `repeat(auto-fit, minmax(${size}px, auto))`,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={colOptions}
|
||||
value={String(templateColumns)}
|
||||
onChange={({ value }) => layoutManager.changeColumns(value!)}
|
||||
allowCustomValue={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GridLayoutRows({ layoutManager }: { layoutManager: ResponsiveGridLayoutManager }) {
|
||||
const { autoRows } = layoutManager.state.layout.useState();
|
||||
|
||||
const rowOptions: Array<SelectableValue<string>> = [];
|
||||
|
||||
for (const size of sizes) {
|
||||
rowOptions.push({
|
||||
label: t('dashboard.responsive-layout.options.min', 'Min: {{size}}px', { size }),
|
||||
value: `minmax(${size}px, auto)`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const size of sizes) {
|
||||
rowOptions.push({
|
||||
label: t('dashboard.responsive-layout.options.fixed', 'Fixed: {{size}}px', { size }),
|
||||
value: `${size}px`,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={rowOptions}
|
||||
value={String(autoRows)}
|
||||
onChange={({ value }) => layoutManager.changeRows(value!)}
|
||||
allowCustomValue={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
|
||||
import { SceneObject } from '@grafana/scenes';
|
||||
import { Button, Stack, Switch, Text } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
|
||||
import { MultiSelectedEditableDashboardElement } from '../types/MultiSelectedEditableDashboardElement';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
|
||||
export class MultiSelectedRowItemsElement implements MultiSelectedEditableDashboardElement {
|
||||
public readonly isMultiSelectedEditableDashboardElement = true;
|
||||
public readonly typeName = 'Rows';
|
||||
|
||||
private items?: RowItem[];
|
||||
|
||||
constructor(items: SceneObject[]) {
|
||||
this.items = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item instanceof RowItem) {
|
||||
this.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
|
||||
const rows = this.items;
|
||||
|
||||
const rowOptions = useMemo(() => {
|
||||
return new OptionsPaneCategoryDescriptor({
|
||||
title: t('dashboard.edit-pane.row.multi-select.options-header', 'Multi-selected Row options'),
|
||||
id: 'ms-row-options',
|
||||
isOpenDefault: true,
|
||||
}).addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.edit-pane.row.hide', 'Hide row header'),
|
||||
render: () => <RowHeaderSwitch rows={rows} />,
|
||||
})
|
||||
);
|
||||
}, [rows]);
|
||||
|
||||
return [rowOptions];
|
||||
}
|
||||
|
||||
public onDelete = () => {
|
||||
for (const item of this.items || []) {
|
||||
item.onDelete();
|
||||
}
|
||||
};
|
||||
|
||||
renderActions(): ReactNode {
|
||||
return (
|
||||
<Stack direction="column">
|
||||
<Text>
|
||||
<Trans i18nKey="dashboard.edit-pane.row.multi-select.selection-number">No. of rows selected: </Trans>
|
||||
{this.items?.length}
|
||||
</Text>
|
||||
<Stack direction="row">
|
||||
<Button size="sm" variant="secondary" icon="copy" />
|
||||
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete} icon="trash-alt" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function RowHeaderSwitch({ rows }: { rows: RowItem[] | undefined }) {
|
||||
if (!rows) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isHeaderHidden = false } = rows[0].useState();
|
||||
|
||||
return (
|
||||
<Switch
|
||||
value={isHeaderHidden}
|
||||
onChange={() => {
|
||||
for (const row of rows) {
|
||||
row.setState({
|
||||
isHeaderHidden: !isHeaderHidden,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +1,19 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { ReactNode, useMemo, useRef } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import {
|
||||
SceneObjectState,
|
||||
SceneObjectBase,
|
||||
SceneComponentProps,
|
||||
sceneGraph,
|
||||
VariableDependencyConfig,
|
||||
SceneObject,
|
||||
} from '@grafana/scenes';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Icon,
|
||||
Input,
|
||||
RadioButtonGroup,
|
||||
Switch,
|
||||
TextLink,
|
||||
useElementSelection,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { SceneObjectState, SceneObjectBase, sceneGraph, VariableDependencyConfig, SceneObject } from '@grafana/scenes';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect';
|
||||
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
|
||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
|
||||
import { isClonedKey } from '../../utils/clone';
|
||||
import { getDashboardSceneFor, getDefaultVizPanel, getQueryRunnerFor } from '../../utils/utils';
|
||||
import { DashboardScene } from '../DashboardScene';
|
||||
import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
|
||||
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
|
||||
import { BulkActionElement } from '../types/BulkActionElement';
|
||||
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
|
||||
import { EditableDashboardElement } from '../types/EditableDashboardElement';
|
||||
import { LayoutParent } from '../types/LayoutParent';
|
||||
|
||||
import { MultiSelectedRowItemsElement } from './MultiSelectedRowItemsElement';
|
||||
import { getEditOptions, renderActions } from './RowItemEditor';
|
||||
import { RowItemRenderer } from './RowItemRenderer';
|
||||
import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior';
|
||||
import { RowItems } from './RowItems';
|
||||
import { RowsLayoutManager } from './RowsLayoutManager';
|
||||
|
||||
export interface RowItemState extends SceneObjectState {
|
||||
@@ -54,6 +28,8 @@ export class RowItem
|
||||
extends SceneObjectBase<RowItemState>
|
||||
implements LayoutParent, BulkActionElement, EditableDashboardElement
|
||||
{
|
||||
public static Component = RowItemRenderer;
|
||||
|
||||
protected _variableDependency = new VariableDependencyConfig(this, {
|
||||
statePaths: ['title'],
|
||||
});
|
||||
@@ -61,303 +37,77 @@ export class RowItem
|
||||
public readonly isEditableDashboardElement = true;
|
||||
public readonly typeName = 'Row';
|
||||
|
||||
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
|
||||
const row = this;
|
||||
|
||||
const rowOptions = useMemo(() => {
|
||||
return new OptionsPaneCategoryDescriptor({
|
||||
title: t('dashboard.rows-layout.row-options.title', 'Row options'),
|
||||
id: 'row-options',
|
||||
isOpenDefault: true,
|
||||
})
|
||||
.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.rows-layout.row-options.title-option', 'Title'),
|
||||
render: () => <RowTitleInput row={row} />,
|
||||
})
|
||||
)
|
||||
.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.rows-layout.row-options.height.title', 'Height'),
|
||||
render: () => <RowHeightSelect row={row} />,
|
||||
})
|
||||
)
|
||||
.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.rows-layout.row-options.height.hide-row-header', 'Hide row header'),
|
||||
render: () => <RowHeaderSwitch row={row} />,
|
||||
})
|
||||
);
|
||||
}, [row]);
|
||||
|
||||
const rowRepeatOptions = useMemo(() => {
|
||||
const dashboard = getDashboardSceneFor(row);
|
||||
|
||||
return new OptionsPaneCategoryDescriptor({
|
||||
title: t('dashboard.rows-layout.row-options.repeat.title', 'Repeat options'),
|
||||
id: 'row-repeat-options',
|
||||
isOpenDefault: true,
|
||||
}).addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.rows-layout.row-options.repeat.variable.title', 'Variable'),
|
||||
render: () => <RowRepeatSelect row={row} dashboard={dashboard} />,
|
||||
})
|
||||
);
|
||||
}, [row]);
|
||||
|
||||
const { layout } = this.useState();
|
||||
const layoutOptions = useLayoutCategory(layout);
|
||||
|
||||
return [rowOptions, rowRepeatOptions, layoutOptions];
|
||||
}
|
||||
|
||||
public createMultiSelectedElement(items: SceneObject[]) {
|
||||
return new MultiSelectedRowItemsElement(items);
|
||||
}
|
||||
|
||||
public onDelete = () => {
|
||||
const layout = sceneGraph.getAncestor(this, RowsLayoutManager);
|
||||
layout.removeRow(this);
|
||||
};
|
||||
|
||||
public renderActions(): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant="secondary" icon="copy" />
|
||||
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete} icon="trash-alt" />
|
||||
</>
|
||||
);
|
||||
public constructor(state?: Partial<RowItemState>) {
|
||||
super({
|
||||
...state,
|
||||
title: state?.title ?? t('dashboard.rows-layout.row.new', 'New row'),
|
||||
layout: state?.layout ?? ResponsiveGridLayoutManager.createEmpty(),
|
||||
});
|
||||
}
|
||||
|
||||
public getLayout(): DashboardLayoutManager {
|
||||
return this.state.layout;
|
||||
}
|
||||
|
||||
public switchLayout(layout: DashboardLayoutManager): void {
|
||||
public switchLayout(layout: DashboardLayoutManager) {
|
||||
this.setState({ layout });
|
||||
}
|
||||
|
||||
public onCollapseToggle = () => {
|
||||
this.setState({ isCollapsed: !this.state.isCollapsed });
|
||||
};
|
||||
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
|
||||
return getEditOptions(this);
|
||||
}
|
||||
|
||||
public onAddPanel = (vizPanel = getDefaultVizPanel()) => {
|
||||
this.getLayout().addPanel(vizPanel);
|
||||
};
|
||||
public renderActions(): ReactNode {
|
||||
return renderActions(this);
|
||||
}
|
||||
|
||||
public static Component = ({ model }: SceneComponentProps<RowItem>) => {
|
||||
const { layout, title, isCollapsed, height = 'expand', isHeaderHidden, key } = model.useState();
|
||||
const isClone = useMemo(() => isClonedKey(key!), [key]);
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
const { isEditing, showHiddenElements } = dashboard.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const shouldGrow = !isCollapsed && height === 'expand';
|
||||
const { isSelected, onSelect } = useElementSelection(key);
|
||||
public onDelete() {
|
||||
const layout = sceneGraph.getAncestor(this, RowsLayoutManager);
|
||||
layout.removeRow(this);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
styles.wrapper,
|
||||
isCollapsed && styles.wrapperCollapsed,
|
||||
shouldGrow && styles.wrapperGrow,
|
||||
!isClone && isSelected && 'dashboard-selected-element'
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{(!isHeaderHidden || (isEditing && showHiddenElements)) && (
|
||||
<div className={styles.rowHeader}>
|
||||
<button
|
||||
onClick={model.onCollapseToggle}
|
||||
className={styles.rowTitleButton}
|
||||
aria-label={
|
||||
isCollapsed
|
||||
? t('dashboard.rows-layout.row.expand', 'Expand row')
|
||||
: t('dashboard.rows-layout.row.collapse', 'Collapse row')
|
||||
}
|
||||
data-testid={selectors.components.DashboardRow.title(titleInterpolated!)}
|
||||
>
|
||||
<Icon name={isCollapsed ? 'angle-right' : 'angle-down'} />
|
||||
<span className={styles.rowTitle} role="heading">
|
||||
{titleInterpolated}
|
||||
</span>
|
||||
</button>
|
||||
{!isClone && isEditing && (
|
||||
<Button icon="pen" variant="secondary" size="sm" fill="text" onPointerDown={(evt) => onSelect?.(evt)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isCollapsed && <layout.Component model={layout} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
public createMultiSelectedElement(items: SceneObject[]): RowItems {
|
||||
return new RowItems(items.filter((item) => item instanceof RowItem));
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
rowHeader: css({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1),
|
||||
padding: theme.spacing(0, 0, 0.5, 0),
|
||||
margin: theme.spacing(0, 0, 1, 0),
|
||||
alignItems: 'center',
|
||||
public getRepeatVariable(): string | undefined {
|
||||
return this._getRepeatBehavior()?.state.variableName;
|
||||
}
|
||||
|
||||
'&:hover, &:focus-within': {
|
||||
'& > div': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
public onChangeTitle(title: string) {
|
||||
this.setState({ title });
|
||||
}
|
||||
|
||||
'& > div': {
|
||||
marginBottom: 0,
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
}),
|
||||
rowTitleButton: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
minWidth: 0,
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
rowTitle: css({
|
||||
fontSize: theme.typography.h5.fontSize,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
}),
|
||||
wrapper: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
minHeight: '100px',
|
||||
}),
|
||||
wrapperGrow: css({
|
||||
flexGrow: 1,
|
||||
}),
|
||||
wrapperCollapsed: css({
|
||||
flexGrow: 0,
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
minHeight: 'unset',
|
||||
}),
|
||||
rowActions: css({
|
||||
display: 'flex',
|
||||
opacity: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
public onHeaderHiddenToggle(isHeaderHidden = !this.state.isHeaderHidden) {
|
||||
this.setState({ isHeaderHidden });
|
||||
}
|
||||
|
||||
export function RowTitleInput({ row }: { row: RowItem }) {
|
||||
const { title } = row.useState();
|
||||
public onChangeHeight(height: 'expand' | 'min') {
|
||||
this.setState({ height });
|
||||
}
|
||||
|
||||
return <Input value={title} onChange={(e) => row.setState({ title: e.currentTarget.value })} />;
|
||||
}
|
||||
public onChangeRepeat(repeat: string | undefined) {
|
||||
let repeatBehavior = this._getRepeatBehavior();
|
||||
|
||||
export function RowHeaderSwitch({ row }: { row: RowItem }) {
|
||||
const { isHeaderHidden = false } = row.useState();
|
||||
|
||||
return (
|
||||
<Switch
|
||||
value={isHeaderHidden}
|
||||
onChange={() => {
|
||||
row.setState({
|
||||
isHeaderHidden: !row.state.isHeaderHidden,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RowHeightSelect({ row }: { row: RowItem }) {
|
||||
const { height = 'expand' } = row.useState();
|
||||
|
||||
const options: Array<SelectableValue<'expand' | 'min'>> = [
|
||||
{ label: t('dashboard.rows-layout.row-options.height.expand', 'Expand'), value: 'expand' },
|
||||
{ label: t('dashboard.rows-layout.row-options.height.min', 'Min'), value: 'min' },
|
||||
];
|
||||
|
||||
return (
|
||||
<RadioButtonGroup
|
||||
options={options}
|
||||
value={height}
|
||||
onChange={(option) =>
|
||||
row.setState({
|
||||
height: option,
|
||||
})
|
||||
if (repeat) {
|
||||
// Remove repeat behavior if it exists to trigger repeat when adding new one
|
||||
if (repeatBehavior) {
|
||||
repeatBehavior.removeBehavior();
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RowRepeatSelect({ row, dashboard }: { row: RowItem; dashboard: DashboardScene }) {
|
||||
const { layout, $behaviors } = row.useState();
|
||||
repeatBehavior = new RowItemRepeaterBehavior({ variableName: repeat });
|
||||
this.setState({ $behaviors: [...(this.state.$behaviors ?? []), repeatBehavior] });
|
||||
repeatBehavior.activate();
|
||||
} else {
|
||||
repeatBehavior?.removeBehavior();
|
||||
}
|
||||
}
|
||||
|
||||
let repeatBehavior: RowItemRepeaterBehavior | undefined = $behaviors?.find(
|
||||
(b) => b instanceof RowItemRepeaterBehavior
|
||||
);
|
||||
const { variableName } = repeatBehavior?.state ?? {};
|
||||
public onCollapseToggle() {
|
||||
this.setState({ isCollapsed: !this.state.isCollapsed });
|
||||
}
|
||||
|
||||
const isAnyPanelUsingDashboardDS = layout.getVizPanels().some((vizPanel) => {
|
||||
const runner = getQueryRunnerFor(vizPanel);
|
||||
return (
|
||||
runner?.state.datasource?.uid === SHARED_DASHBOARD_QUERY ||
|
||||
(runner?.state.datasource?.uid === MIXED_DATASOURCE_NAME &&
|
||||
runner?.state.queries.some((query) => query.datasource?.uid === SHARED_DASHBOARD_QUERY))
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<RepeatRowSelect2
|
||||
sceneContext={dashboard}
|
||||
repeat={variableName}
|
||||
onChange={(repeat) => {
|
||||
if (repeat) {
|
||||
// Remove repeat behavior if it exists to trigger repeat when adding new one
|
||||
if (repeatBehavior) {
|
||||
repeatBehavior.removeBehavior();
|
||||
}
|
||||
|
||||
repeatBehavior = new RowItemRepeaterBehavior({ variableName: repeat });
|
||||
row.setState({ $behaviors: [...(row.state.$behaviors ?? []), repeatBehavior] });
|
||||
repeatBehavior.activate();
|
||||
} else {
|
||||
repeatBehavior?.removeBehavior();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isAnyPanelUsingDashboardDS ? (
|
||||
<Alert
|
||||
data-testid={selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage}
|
||||
severity="warning"
|
||||
title=""
|
||||
topSpacing={3}
|
||||
bottomSpacing={0}
|
||||
>
|
||||
<p>
|
||||
<Trans i18nKey="dashboard.rows-layout.row.repeat.warning">
|
||||
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.
|
||||
</Trans>
|
||||
</p>
|
||||
<TextLink
|
||||
external
|
||||
href={
|
||||
'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows'
|
||||
}
|
||||
>
|
||||
<Trans i18nKey="dashboard.rows-layout.row.repeat.learn-more">Learn more</Trans>
|
||||
</TextLink>
|
||||
</Alert>
|
||||
) : undefined}
|
||||
</>
|
||||
);
|
||||
private _getRepeatBehavior(): RowItemRepeaterBehavior | undefined {
|
||||
return this.state.$behaviors?.find((b) => b instanceof RowItemRepeaterBehavior);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Alert, Button, Input, RadioButtonGroup, Switch, TextLink } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect';
|
||||
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
|
||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
|
||||
import { getDashboardSceneFor, getQueryRunnerFor } from '../../utils/utils';
|
||||
import { DashboardScene } from '../DashboardScene';
|
||||
import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
|
||||
export function getEditOptions(model: RowItem): OptionsPaneCategoryDescriptor[] {
|
||||
const rowOptions = useMemo(() => {
|
||||
return new OptionsPaneCategoryDescriptor({
|
||||
title: t('dashboard.rows-layout.row-options.title', 'Row options'),
|
||||
id: 'row-options',
|
||||
isOpenDefault: true,
|
||||
})
|
||||
.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.rows-layout.row-options.title-option', 'Title'),
|
||||
render: () => <RowTitleInput row={model} />,
|
||||
})
|
||||
)
|
||||
.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.rows-layout.row-options.height.title', 'Height'),
|
||||
render: () => <RowHeightSelect row={model} />,
|
||||
})
|
||||
)
|
||||
.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.rows-layout.row-options.height.hide-row-header', 'Hide row header'),
|
||||
render: () => <RowHeaderSwitch row={model} />,
|
||||
})
|
||||
);
|
||||
}, [model]);
|
||||
|
||||
const rowRepeatOptions = useMemo(() => {
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
|
||||
return new OptionsPaneCategoryDescriptor({
|
||||
title: t('dashboard.rows-layout.row-options.repeat.title', 'Repeat options'),
|
||||
id: 'row-repeat-options',
|
||||
isOpenDefault: true,
|
||||
}).addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.rows-layout.row-options.repeat.variable.title', 'Variable'),
|
||||
render: () => <RowRepeatSelect row={model} dashboard={dashboard} />,
|
||||
})
|
||||
);
|
||||
}, [model]);
|
||||
|
||||
const { layout } = model.useState();
|
||||
const layoutOptions = useLayoutCategory(layout);
|
||||
|
||||
return [rowOptions, rowRepeatOptions, layoutOptions];
|
||||
}
|
||||
|
||||
export function renderActions(model: RowItem) {
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant="secondary" icon="copy" />
|
||||
<Button size="sm" variant="destructive" fill="outline" onClick={() => model.onDelete()} icon="trash-alt" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RowTitleInput({ row }: { row: RowItem }) {
|
||||
const { title } = row.useState();
|
||||
|
||||
return <Input value={title} onChange={(e) => row.onChangeTitle(e.currentTarget.value)} />;
|
||||
}
|
||||
|
||||
function RowHeaderSwitch({ row }: { row: RowItem }) {
|
||||
const { isHeaderHidden = false } = row.useState();
|
||||
|
||||
return <Switch value={isHeaderHidden} onChange={() => row.onHeaderHiddenToggle()} />;
|
||||
}
|
||||
|
||||
function RowHeightSelect({ row }: { row: RowItem }) {
|
||||
const { height = 'expand' } = row.useState();
|
||||
|
||||
const options: Array<SelectableValue<'expand' | 'min'>> = [
|
||||
{ label: t('dashboard.rows-layout.row-options.height.expand', 'Expand'), value: 'expand' },
|
||||
{ label: t('dashboard.rows-layout.row-options.height.min', 'Min'), value: 'min' },
|
||||
];
|
||||
|
||||
return <RadioButtonGroup options={options} value={height} onChange={(option) => row.onChangeHeight(option)} />;
|
||||
}
|
||||
|
||||
function RowRepeatSelect({ row, dashboard }: { row: RowItem; dashboard: DashboardScene }) {
|
||||
const { layout } = row.useState();
|
||||
|
||||
const isAnyPanelUsingDashboardDS = layout.getVizPanels().some((vizPanel) => {
|
||||
const runner = getQueryRunnerFor(vizPanel);
|
||||
return (
|
||||
runner?.state.datasource?.uid === SHARED_DASHBOARD_QUERY ||
|
||||
(runner?.state.datasource?.uid === MIXED_DATASOURCE_NAME &&
|
||||
runner?.state.queries.some((query) => query.datasource?.uid === SHARED_DASHBOARD_QUERY))
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<RepeatRowSelect2
|
||||
sceneContext={dashboard}
|
||||
repeat={row.getRepeatVariable()}
|
||||
onChange={(repeat) => row.onChangeRepeat(repeat)}
|
||||
/>
|
||||
{isAnyPanelUsingDashboardDS ? (
|
||||
<Alert
|
||||
data-testid={selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage}
|
||||
severity="warning"
|
||||
title=""
|
||||
topSpacing={3}
|
||||
bottomSpacing={0}
|
||||
>
|
||||
<p>
|
||||
<Trans i18nKey="dashboard.rows-layout.row.repeat.warning">
|
||||
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.
|
||||
</Trans>
|
||||
</p>
|
||||
<TextLink
|
||||
external
|
||||
href={
|
||||
'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows'
|
||||
}
|
||||
>
|
||||
<Trans i18nKey="dashboard.rows-layout.row.repeat.learn-more">Learn more</Trans>
|
||||
</TextLink>
|
||||
</Alert>
|
||||
) : undefined}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { SceneComponentProps, sceneGraph } from '@grafana/scenes';
|
||||
import { Button, Icon, useElementSelection, useStyles2 } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { isClonedKey } from '../../utils/clone';
|
||||
import { getDashboardSceneFor } from '../../utils/utils';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
|
||||
export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
|
||||
const { layout, title, isCollapsed, height = 'expand', isHeaderHidden, key } = model.useState();
|
||||
const isClone = useMemo(() => isClonedKey(key!), [key]);
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
const { isEditing, showHiddenElements } = dashboard.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const shouldGrow = !isCollapsed && height === 'expand';
|
||||
const { isSelected, onSelect } = useElementSelection(key);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
styles.wrapper,
|
||||
isCollapsed && styles.wrapperCollapsed,
|
||||
shouldGrow && styles.wrapperGrow,
|
||||
!isClone && isSelected && 'dashboard-selected-element'
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{(!isHeaderHidden || (isEditing && showHiddenElements)) && (
|
||||
<div className={styles.rowHeader}>
|
||||
<button
|
||||
onClick={() => model.onCollapseToggle()}
|
||||
className={styles.rowTitleButton}
|
||||
aria-label={
|
||||
isCollapsed
|
||||
? t('dashboard.rows-layout.row.expand', 'Expand row')
|
||||
: t('dashboard.rows-layout.row.collapse', 'Collapse row')
|
||||
}
|
||||
data-testid={selectors.components.DashboardRow.title(titleInterpolated!)}
|
||||
>
|
||||
<Icon name={isCollapsed ? 'angle-right' : 'angle-down'} />
|
||||
<span className={styles.rowTitle} role="heading">
|
||||
{titleInterpolated}
|
||||
</span>
|
||||
</button>
|
||||
{!isClone && isEditing && (
|
||||
<Button icon="pen" variant="secondary" size="sm" fill="text" onPointerDown={(evt) => onSelect?.(evt)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isCollapsed && <layout.Component model={layout} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
rowHeader: css({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1),
|
||||
padding: theme.spacing(0, 0, 0.5, 0),
|
||||
margin: theme.spacing(0, 0, 1, 0),
|
||||
alignItems: 'center',
|
||||
|
||||
'&:hover, &:focus-within': {
|
||||
'& > div': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
|
||||
'& > div': {
|
||||
marginBottom: 0,
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
}),
|
||||
rowTitleButton: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
minWidth: 0,
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
rowTitle: css({
|
||||
fontSize: theme.typography.h5.fontSize,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
}),
|
||||
wrapper: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
minHeight: '100px',
|
||||
}),
|
||||
wrapperGrow: css({
|
||||
flexGrow: 1,
|
||||
}),
|
||||
wrapperCollapsed: css({
|
||||
flexGrow: 0,
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
minHeight: 'unset',
|
||||
}),
|
||||
rowActions: css({
|
||||
display: 'flex',
|
||||
opacity: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
+3
-3
@@ -141,7 +141,7 @@ function buildScene(
|
||||
$behaviors: [repeatBehavior],
|
||||
layout: DefaultGridLayoutManager.fromGridItems([
|
||||
new DashboardGridItem({
|
||||
key: 'griditem-1',
|
||||
key: 'grid-item-1',
|
||||
x: 0,
|
||||
y: 11,
|
||||
width: 24,
|
||||
@@ -155,13 +155,13 @@ function buildScene(
|
||||
title: 'Row at the bottom',
|
||||
layout: DefaultGridLayoutManager.fromGridItems([
|
||||
new DashboardGridItem({
|
||||
key: 'griditem-2',
|
||||
key: 'grid-item-2',
|
||||
x: 0,
|
||||
y: 17,
|
||||
body: buildTextPanel('text-2', 'Panel inside row, server = $server'),
|
||||
}),
|
||||
new DashboardGridItem({
|
||||
key: 'griditem-3',
|
||||
key: 'grid-item-3',
|
||||
x: 0,
|
||||
y: 25,
|
||||
body: buildTextPanel('text-3', 'Panel inside row, server = $server'),
|
||||
|
||||
@@ -22,10 +22,6 @@ interface RowItemRepeaterBehaviorState extends SceneObjectState {
|
||||
variableName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This behavior will run an effect function when specified variables change
|
||||
*/
|
||||
|
||||
export class RowItemRepeaterBehavior extends SceneObjectBase<RowItemRepeaterBehaviorState> {
|
||||
protected _variableDependency = new VariableDependencyConfig(this, {
|
||||
variableNames: [this.state.variableName],
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
|
||||
import { MultiSelectedEditableDashboardElement } from '../types/MultiSelectedEditableDashboardElement';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
import { getEditOptions, renderActions } from './RowItemsEditor';
|
||||
|
||||
export class RowItems implements MultiSelectedEditableDashboardElement {
|
||||
public readonly isMultiSelectedEditableDashboardElement = true;
|
||||
public readonly typeName = 'Rows';
|
||||
public readonly key: string;
|
||||
|
||||
public constructor(private _rows: RowItem[]) {
|
||||
this.key = uuidv4();
|
||||
}
|
||||
|
||||
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
|
||||
return getEditOptions(this);
|
||||
}
|
||||
|
||||
public renderActions(): ReactNode {
|
||||
return renderActions(this);
|
||||
}
|
||||
|
||||
public getRows(): RowItem[] {
|
||||
return this._rows;
|
||||
}
|
||||
|
||||
public onDelete() {
|
||||
this._rows.forEach((row) => row.onDelete());
|
||||
}
|
||||
|
||||
public onHeaderHiddenToggle(value: boolean, indeterminate: boolean) {
|
||||
this._rows.forEach((row) => row.onHeaderHiddenToggle(indeterminate ? true : !value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Button, Checkbox, Stack, Text } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
|
||||
import { RowItems } from './RowItems';
|
||||
|
||||
export function getEditOptions(model: RowItems): OptionsPaneCategoryDescriptor[] {
|
||||
const options = new OptionsPaneCategoryDescriptor({
|
||||
title: t('dashboard.edit-pane.row.multi-select.options-header', 'Multi-selected Row options'),
|
||||
id: `ms-row-options-${model.key}`,
|
||||
isOpenDefault: true,
|
||||
}).addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.edit-pane.row.header.title', 'Row header'),
|
||||
render: () => <RowHeaderCheckboxMulti model={model} />,
|
||||
})
|
||||
);
|
||||
|
||||
return [options];
|
||||
}
|
||||
|
||||
export function renderActions(model: RowItems) {
|
||||
const rows = model.getRows();
|
||||
|
||||
return (
|
||||
<Stack direction="column">
|
||||
<Text>
|
||||
<Trans i18nKey="dashboard.edit-pane.row.multi-select.selection-number" values={{ length: rows.length }}>
|
||||
No. of rows selected: {{ length }}
|
||||
</Trans>
|
||||
</Text>
|
||||
<Stack direction="row">
|
||||
<Button size="sm" variant="secondary" icon="copy" />
|
||||
<Button size="sm" variant="destructive" fill="outline" onClick={() => model.onDelete()} icon="trash-alt" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function RowHeaderCheckboxMulti({ model }: { model: RowItems }) {
|
||||
const rows = model.getRows();
|
||||
|
||||
let value = false;
|
||||
let indeterminate = false;
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const { isHeaderHidden } = rows[i].useState();
|
||||
|
||||
const prevElement = rows[i - 1];
|
||||
indeterminate = indeterminate || (prevElement && !!prevElement.state.isHeaderHidden !== !!isHeaderHidden);
|
||||
|
||||
value = value || !!isHeaderHidden;
|
||||
}
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
label={t('dashboard.edit-pane.row.header.hide', 'Hide')}
|
||||
value={value}
|
||||
indeterminate={indeterminate}
|
||||
onChange={() => model.onHeaderHiddenToggle(value, indeterminate)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,24 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import {
|
||||
SceneComponentProps,
|
||||
sceneGraph,
|
||||
SceneGridItemLike,
|
||||
SceneGridRow,
|
||||
SceneObjectBase,
|
||||
SceneObjectState,
|
||||
VizPanel,
|
||||
} from '@grafana/scenes';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { SceneGridItemLike, SceneGridRow, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { isClonedKey } from '../../utils/clone';
|
||||
import { DashboardScene } from '../DashboardScene';
|
||||
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
|
||||
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
|
||||
import { RowRepeaterBehavior } from '../layout-default/RowRepeaterBehavior';
|
||||
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
|
||||
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior';
|
||||
import { RowLayoutManagerRenderer } from './RowsLayoutManagerRenderer';
|
||||
|
||||
interface RowsLayoutManagerState extends SceneObjectState {
|
||||
rows: RowItem[];
|
||||
}
|
||||
|
||||
export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> implements DashboardLayoutManager {
|
||||
public static Component = RowLayoutManagerRenderer;
|
||||
|
||||
public readonly isDashboardLayoutManager = true;
|
||||
|
||||
public static readonly descriptor = {
|
||||
@@ -44,47 +34,21 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
||||
|
||||
public readonly descriptor = RowsLayoutManager.descriptor;
|
||||
|
||||
public editModeChanged(isEditing: boolean): void {}
|
||||
|
||||
public addPanel(vizPanel: VizPanel): void {
|
||||
public addPanel(vizPanel: VizPanel) {
|
||||
// Try to add new panels to the selected row
|
||||
const selectedObject = this.getSelectedObject();
|
||||
if (selectedObject instanceof RowItem) {
|
||||
return selectedObject.onAddPanel(vizPanel);
|
||||
const selectedRows = dashboardSceneGraph.getAllSelectedObjects(this).filter((obj) => obj instanceof RowItem);
|
||||
if (selectedRows.length > 0) {
|
||||
return selectedRows.forEach((row) => row.getLayout().addPanel(vizPanel));
|
||||
}
|
||||
|
||||
// If we don't have selected row add it to the first row
|
||||
if (this.state.rows.length > 0) {
|
||||
return this.state.rows[0].onAddPanel(vizPanel);
|
||||
return this.state.rows[0].getLayout().addPanel(vizPanel);
|
||||
}
|
||||
|
||||
// Otherwise fallback to adding a new row and a panel
|
||||
this.addNewRow();
|
||||
this.state.rows[this.state.rows.length - 1].onAddPanel(vizPanel);
|
||||
}
|
||||
|
||||
public addNewRow(): void {
|
||||
this.setState({
|
||||
rows: [
|
||||
...this.state.rows,
|
||||
new RowItem({
|
||||
title: t('dashboard.rows-layout.row.new', 'New row'),
|
||||
layout: ResponsiveGridLayoutManager.createEmpty(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
public removePanel(panel: VizPanel) {}
|
||||
|
||||
public removeRow(row: RowItem) {
|
||||
this.setState({
|
||||
rows: this.state.rows.filter((r) => r !== row),
|
||||
});
|
||||
}
|
||||
|
||||
public duplicatePanel(panel: VizPanel): void {
|
||||
throw new Error('Method not implemented.');
|
||||
this.state.rows[this.state.rows.length - 1].getLayout().addPanel(vizPanel);
|
||||
}
|
||||
|
||||
public getVizPanels(): VizPanel[] {
|
||||
@@ -98,33 +62,38 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
||||
return panels;
|
||||
}
|
||||
|
||||
public getOptions() {
|
||||
return [];
|
||||
public addNewRow() {
|
||||
this.setState({ rows: [...this.state.rows, new RowItem()] });
|
||||
}
|
||||
|
||||
public editModeChanged(isEditing: boolean) {
|
||||
this.state.rows.forEach((row) => row.getLayout().editModeChanged?.(isEditing));
|
||||
}
|
||||
|
||||
public activateRepeaters() {
|
||||
this.state.rows.forEach((row) => {
|
||||
if (row.state.$behaviors) {
|
||||
for (const behavior of row.state.$behaviors) {
|
||||
if (behavior instanceof RowItemRepeaterBehavior && !row.isActive) {
|
||||
row.activate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!row.getLayout().isActive) {
|
||||
row.getLayout().activate();
|
||||
}
|
||||
if (!row.isActive) {
|
||||
row.activate();
|
||||
}
|
||||
|
||||
const behavior = (row.state.$behaviors ?? []).find((b) => b instanceof RowItemRepeaterBehavior);
|
||||
|
||||
if (!behavior?.isActive) {
|
||||
behavior?.activate();
|
||||
}
|
||||
|
||||
row.getLayout().activateRepeaters?.();
|
||||
});
|
||||
}
|
||||
|
||||
public getSelectedObject() {
|
||||
return sceneGraph.getAncestor(this, DashboardScene).state.editPane.state.selection?.getFirstObject();
|
||||
public removeRow(row: RowItem) {
|
||||
this.setState({
|
||||
rows: this.state.rows.filter((r) => r !== row),
|
||||
});
|
||||
}
|
||||
|
||||
public static createEmpty() {
|
||||
return new RowsLayoutManager({ rows: [] });
|
||||
public static createEmpty(): RowsLayoutManager {
|
||||
return new RowsLayoutManager({ rows: [new RowItem()] });
|
||||
}
|
||||
|
||||
public static createFromLayout(layout: DashboardLayoutManager): RowsLayoutManager {
|
||||
@@ -175,7 +144,7 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
||||
rows = config.map(
|
||||
(rowConfig) =>
|
||||
new RowItem({
|
||||
title: rowConfig.title ?? t('dashboard.rows-layout.row.new', 'New row'),
|
||||
title: rowConfig.title,
|
||||
isCollapsed: !!rowConfig.isCollapsed,
|
||||
layout: DefaultGridLayoutManager.fromGridItems(
|
||||
rowConfig.children,
|
||||
@@ -186,34 +155,14 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
||||
})
|
||||
);
|
||||
} else {
|
||||
rows = [new RowItem({ layout: layout.clone(), title: t('dashboard.rows-layout.row.new', 'New row') })];
|
||||
rows = [new RowItem({ layout: layout.clone() })];
|
||||
}
|
||||
|
||||
// Ensure we always get at least one row
|
||||
if (rows.length === 0) {
|
||||
rows = [new RowItem()];
|
||||
}
|
||||
|
||||
return new RowsLayoutManager({ rows });
|
||||
}
|
||||
|
||||
public static Component = ({ model }: SceneComponentProps<RowsLayoutManager>) => {
|
||||
const { rows } = model.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{rows.map((row) => (
|
||||
<row.Component model={row} key={row.state.key!} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
wrapper: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
flexGrow: 1,
|
||||
width: '100%',
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { SceneComponentProps } from '@grafana/scenes';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { RowsLayoutManager } from './RowsLayoutManager';
|
||||
|
||||
export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayoutManager>) {
|
||||
const { rows } = model.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{rows.map((row) => (
|
||||
<row.Component model={row} key={row.state.key!} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
wrapper: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
flexGrow: 1,
|
||||
width: '100%',
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
export interface BulkActionElement {
|
||||
/**
|
||||
* Called when the element should be deleted
|
||||
*/
|
||||
onDelete(): void;
|
||||
|
||||
/**
|
||||
* Called when the element should be copied
|
||||
*/
|
||||
onCopy?(): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,13 +25,13 @@ export interface DashboardLayoutManager<S = {}> extends SceneObject {
|
||||
* Remove an element / panel
|
||||
* @param panel
|
||||
*/
|
||||
removePanel(panel: VizPanel): void;
|
||||
removePanel?(panel: VizPanel): void;
|
||||
|
||||
/**
|
||||
* Creates a copy of an existing element and adds it to the layout
|
||||
* @param panel
|
||||
*/
|
||||
duplicatePanel(panel: VizPanel): void;
|
||||
duplicatePanel?(panel: VizPanel): void;
|
||||
|
||||
/**
|
||||
* getVizPanels
|
||||
|
||||
@@ -6,6 +6,15 @@ import { DashboardLayoutManager } from './DashboardLayoutManager';
|
||||
* This interface is needed to support layouts existing on different levels of the scene (DashboardScene and inside the TabsLayoutManager)
|
||||
*/
|
||||
export interface LayoutParent extends SceneObject {
|
||||
/**
|
||||
* Returns the inner layout manager
|
||||
*/
|
||||
getLayout(): DashboardLayoutManager;
|
||||
|
||||
/**
|
||||
* Switches the inner layout manager
|
||||
* @param newLayout
|
||||
*/
|
||||
switchLayout(newLayout: DashboardLayoutManager): void;
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -13,6 +13,11 @@ export interface MultiSelectedEditableDashboardElement {
|
||||
*/
|
||||
typeName: Readonly<string>;
|
||||
|
||||
/**
|
||||
* Extremely useful for being able to access the useState inside the contained items
|
||||
*/
|
||||
key: Readonly<string>;
|
||||
|
||||
/**
|
||||
* Hook that returns edit pane options
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { VizPanelLinks } from '../scene/PanelLinks';
|
||||
|
||||
import { isClonedKey } from './clone';
|
||||
import { getLayoutManagerFor, getPanelIdForVizPanel } from './utils';
|
||||
import { getDashboardSceneFor, getLayoutManagerFor, getPanelIdForVizPanel } from './utils';
|
||||
|
||||
function getTimePicker(scene: DashboardScene) {
|
||||
return scene.state.controls?.state.timePicker;
|
||||
@@ -62,6 +62,14 @@ function getDataLayers(scene: DashboardScene): DashboardDataLayerSet {
|
||||
return data;
|
||||
}
|
||||
|
||||
function getAllSelectedObjects(scene: SceneObject): SceneObject[] {
|
||||
return (
|
||||
getDashboardSceneFor(scene)
|
||||
.state.editPane.state.selection?.getSelectionEntries()
|
||||
.map(([, ref]) => ref.resolve()) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
export function getCursorSync(scene: DashboardScene) {
|
||||
const cursorSync = scene.state.$behaviors?.find((b) => b instanceof behaviors.CursorSync);
|
||||
|
||||
@@ -78,6 +86,7 @@ export const dashboardSceneGraph = {
|
||||
getPanelLinks,
|
||||
getVizPanels,
|
||||
getDataLayers,
|
||||
getAllSelectedObjects,
|
||||
getCursorSync,
|
||||
getLayoutManagerFor,
|
||||
getNextPanelId,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { shallowEqual } from 'react-redux';
|
||||
|
||||
import { DataSourceInstanceSettings, RawTimeRange, GrafanaTheme2 } from '@grafana/data';
|
||||
import { Components } from '@grafana/e2e-selectors';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import {
|
||||
defaultIntervals,
|
||||
PageToolbar,
|
||||
@@ -29,6 +29,7 @@ import { ExploreTimeControls } from './ExploreTimeControls';
|
||||
import { LiveTailButton } from './LiveTailButton';
|
||||
import { useQueriesDrawerContext } from './QueriesDrawer/QueriesDrawerContext';
|
||||
import { QueriesDrawerDropdown } from './QueriesDrawer/QueriesDrawerDropdown';
|
||||
import { useQueryLibraryContext } from './QueryLibrary/QueryLibraryContext';
|
||||
import { ShortLinkButtonMenu } from './ShortLinkButtonMenu';
|
||||
import { ToolbarExtensionPoint } from './extensions/ToolbarExtensionPoint';
|
||||
import { changeDatasource } from './state/datasource';
|
||||
@@ -92,6 +93,7 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
|
||||
const isCorrelationsEditorMode = correlationDetails?.editorMode || false;
|
||||
const isLeftPane = useSelector(isLeftPaneSelector(exploreId));
|
||||
const { drawerOpened, setDrawerOpened } = useQueriesDrawerContext();
|
||||
const { queryLibraryEnabled } = useQueryLibraryContext();
|
||||
|
||||
const shouldRotateSplitIcon = useMemo(
|
||||
() => (isLeftPane && isLargerPane) || (!isLeftPane && !isLargerPane),
|
||||
@@ -206,7 +208,7 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
|
||||
|
||||
const navBarActions = [<ShortLinkButtonMenu key="share" />];
|
||||
|
||||
if (config.featureToggles.queryLibrary) {
|
||||
if (queryLibraryEnabled) {
|
||||
navBarActions.unshift(<QueriesDrawerDropdown key="queryLibrary" variant="full" />);
|
||||
} else {
|
||||
navBarActions.unshift(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { ComponentProps, useState } from 'react';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Button, ButtonGroup, Dropdown, Menu, ToolbarButton } from '@grafana/ui';
|
||||
import { useStyles2 } from '@grafana/ui/';
|
||||
import { t } from 'app/core/internationalization';
|
||||
@@ -9,7 +8,6 @@ import { t } from 'app/core/internationalization';
|
||||
import { createDatasourcesList } from '../../../core/utils/richHistory';
|
||||
import { useSelector } from '../../../types';
|
||||
import ExploreRunQueryButton from '../ExploreRunQueryButton';
|
||||
import { queryLibraryTrackToggle } from '../QueryLibrary/QueryLibraryAnalyticsEvents';
|
||||
import { useQueryLibraryContext } from '../QueryLibrary/QueryLibraryContext';
|
||||
import { QueryActionButton } from '../QueryLibrary/types';
|
||||
import { selectExploreDSMaps } from '../state/selectors';
|
||||
@@ -39,6 +37,7 @@ export function QueriesDrawerDropdown({ variant }: Props) {
|
||||
openDrawer: openQueryLibraryDrawer,
|
||||
closeDrawer: closeQueryLibraryDrawer,
|
||||
isDrawerOpen: isQueryLibraryDrawerOpen,
|
||||
queryLibraryEnabled,
|
||||
} = useQueryLibraryContext();
|
||||
|
||||
const [queryOption, setQueryOption] = useState<'library' | 'history'>('library');
|
||||
@@ -48,7 +47,7 @@ export function QueriesDrawerDropdown({ variant }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
// In case query library is not enabled we show only simple button for query history in the parent.
|
||||
if (!config.featureToggles.queryLibrary) {
|
||||
if (!queryLibraryEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -73,7 +72,6 @@ export function QueriesDrawerDropdown({ variant }: Props) {
|
||||
|
||||
openQueryLibraryDrawer(activeDatasources, ExploreRunQueryButtonWrapper);
|
||||
}
|
||||
queryLibraryTrackToggle(!isQueryLibraryDrawerOpen);
|
||||
}
|
||||
|
||||
const menu = (
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { DataQuery } from '@grafana/schema';
|
||||
import { Modal } from '@grafana/ui';
|
||||
|
||||
import { t } from '../../../core/internationalization';
|
||||
|
||||
import { queryLibraryTrackAddFromQueryRow } from './QueryLibraryAnalyticsEvents';
|
||||
import { QueryTemplateForm } from './QueryTemplateForm';
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
close: () => void;
|
||||
query?: DataQuery;
|
||||
};
|
||||
|
||||
export function AddToQueryLibraryModal({ query, close, isOpen }: Props) {
|
||||
return (
|
||||
<Modal
|
||||
title={t('explore.query-template-modal.add-title', 'Add query to Query Library')}
|
||||
isOpen={isOpen}
|
||||
onDismiss={() => close()}
|
||||
>
|
||||
<QueryTemplateForm
|
||||
onCancel={() => {
|
||||
close();
|
||||
}}
|
||||
onSave={(isSuccess) => {
|
||||
if (isSuccess) {
|
||||
close();
|
||||
queryLibraryTrackAddFromQueryRow(query?.datasource?.type || '');
|
||||
}
|
||||
}}
|
||||
queryToAdd={query!}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useLocalStorage } from 'react-use';
|
||||
|
||||
import { QueryLibraryExpmInfo } from './QueryLibraryExpmInfo';
|
||||
import { QueryTemplatesList } from './QueryTemplatesList';
|
||||
import { QueryActionButton } from './types';
|
||||
|
||||
export interface QueryLibraryProps {
|
||||
// List of active datasources to filter the query library by
|
||||
// E.g in Explore the active datasources are the datasources that are currently selected in the query editor
|
||||
activeDatasources?: string[];
|
||||
queryActionButton?: QueryActionButton;
|
||||
}
|
||||
|
||||
export const QUERY_LIBRARY_LOCAL_STORAGE_KEYS = {
|
||||
explore: {
|
||||
notifyUserAboutQueryLibrary: 'grafana.explore.query-library.notifyUserAboutQueryLibrary',
|
||||
newButton: 'grafana.explore.query-library.newButton',
|
||||
},
|
||||
};
|
||||
|
||||
export function QueryLibrary({ activeDatasources, queryActionButton }: QueryLibraryProps) {
|
||||
const [notifyUserAboutQueryLibrary, setNotifyUserAboutQueryLibrary] = useLocalStorage(
|
||||
QUERY_LIBRARY_LOCAL_STORAGE_KEYS.explore.notifyUserAboutQueryLibrary,
|
||||
true
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<QueryLibraryExpmInfo
|
||||
isOpen={notifyUserAboutQueryLibrary || false}
|
||||
onDismiss={() => setNotifyUserAboutQueryLibrary(false)}
|
||||
/>
|
||||
<QueryTemplatesList activeDatasources={activeDatasources} queryActionButton={queryActionButton} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
|
||||
const QUERY_LIBRARY_EXPLORE_EVENT = 'query_library_explore_clicked';
|
||||
|
||||
export function queryLibraryTrackToggle(open: boolean) {
|
||||
reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, {
|
||||
item: 'query_library_toggle',
|
||||
type: open ? 'open' : 'close',
|
||||
});
|
||||
}
|
||||
|
||||
export function queryLibraryTrackAddFromQueryHistory(datasourceType: string) {
|
||||
reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, {
|
||||
item: 'add_query_from_query_history',
|
||||
type: datasourceType,
|
||||
});
|
||||
}
|
||||
|
||||
export function queryLibraryTrackAddFromQueryHistoryAddModalShown() {
|
||||
reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, {
|
||||
item: 'add_query_modal_from_query_history',
|
||||
type: 'open',
|
||||
});
|
||||
}
|
||||
|
||||
export function queryLibraryTrackAddFromQueryRow(datasourceType: string) {
|
||||
reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, {
|
||||
item: 'add_query_from_query_row',
|
||||
type: datasourceType,
|
||||
});
|
||||
}
|
||||
|
||||
export function queryLibaryTrackDeleteQuery() {
|
||||
reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, {
|
||||
item: 'delete_query',
|
||||
});
|
||||
}
|
||||
|
||||
export function queryLibraryTrackRunQuery(datasourceType: string) {
|
||||
reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, {
|
||||
item: 'run_query',
|
||||
type: datasourceType,
|
||||
});
|
||||
}
|
||||
|
||||
export function queryLibraryTrackAddOrEditDescription() {
|
||||
reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, {
|
||||
item: 'add_or_edit_description',
|
||||
});
|
||||
}
|
||||
|
||||
export function queryLibraryTrackFilterDatasource() {
|
||||
reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, {
|
||||
item: 'filter_datasource',
|
||||
});
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { ComponentType } from 'react';
|
||||
|
||||
import { PromQuery } from '@grafana/prometheus';
|
||||
|
||||
import { useQueryLibraryContext, QueryLibraryContextProvider, QueryLibraryContextType } from './QueryLibraryContext';
|
||||
|
||||
// Bit of mocking here mainly so we don't have to mock too much of the API calls here and keep this test focused on the
|
||||
// context state management and correct rendering.
|
||||
|
||||
jest.mock('./AddToQueryLibraryModal', () => ({
|
||||
__esModule: true,
|
||||
AddToQueryLibraryModal: (props: { isOpen: boolean; query: unknown }) =>
|
||||
props.isOpen && <div>QUERY_MODAL {JSON.stringify(props.query)}</div>,
|
||||
}));
|
||||
|
||||
jest.mock('./QueryLibraryDrawer', () => ({
|
||||
__esModule: true,
|
||||
QueryLibraryDrawer: (props: {
|
||||
isOpen: boolean;
|
||||
activeDatasources: string[] | undefined;
|
||||
queryActionButton: ComponentType;
|
||||
}) =>
|
||||
props.isOpen && (
|
||||
<div>
|
||||
QUERY_DRAWER {JSON.stringify(props.activeDatasources)} {props.queryActionButton && <props.queryActionButton />}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
function setup() {
|
||||
let ctx: { current: QueryLibraryContextType | undefined } = { current: undefined };
|
||||
function TestComp() {
|
||||
ctx.current = useQueryLibraryContext();
|
||||
return <div></div>;
|
||||
}
|
||||
// rendering instead of just using renderHook so we can check if the modal and drawer actually render.
|
||||
const renderResult = render(
|
||||
<QueryLibraryContextProvider>
|
||||
<TestComp />
|
||||
</QueryLibraryContextProvider>
|
||||
);
|
||||
|
||||
return { ctx, renderResult };
|
||||
}
|
||||
|
||||
describe('QueryLibraryContext', () => {
|
||||
it('should not render modal or drawer by default', () => {
|
||||
setup();
|
||||
// should catch both modal and drawer
|
||||
expect(screen.queryByText(/QUERY_MODAL/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/QUERY_DRAWER/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be able to open modal', async () => {
|
||||
const { ctx } = setup();
|
||||
act(() => {
|
||||
ctx.current!.openAddQueryModal({ refId: 'A', expr: 'http_requests_total{job="test"}' } as PromQuery);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/QUERY_MODAL/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/http_requests_total\{job=\\"test\\"}/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to open drawer', async () => {
|
||||
const { ctx } = setup();
|
||||
act(() => {
|
||||
ctx.current!.openDrawer(['PROM_TEST_DS'], () => <div>QUERY_ACTION_BUTTON</div>);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/QUERY_DRAWER/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/PROM_TEST_DS/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/QUERY_ACTION_BUTTON/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,8 @@
|
||||
import { PropsWithChildren, useState, createContext, useContext, useCallback, useMemo } from 'react';
|
||||
import { createContext, ReactNode, useContext } from 'react';
|
||||
|
||||
import { DataQuery } from '@grafana/schema';
|
||||
|
||||
import { AddToQueryLibraryModal } from './AddToQueryLibraryModal';
|
||||
import { QueryLibraryDrawer } from './QueryLibraryDrawer';
|
||||
import { QueryActionButton, QueryActionButtonProps } from './types';
|
||||
import { QueryActionButton } from './types';
|
||||
|
||||
/**
|
||||
* Context with state and action to interact with Query Library. The Query Library feature consists of a drawer
|
||||
@@ -19,17 +17,33 @@ export type QueryLibraryContextType = {
|
||||
* @param datasourceFilters Data source names that will be used for initial filter in the library.
|
||||
* @param queryActionButton Action button will be shown in the library next to the query and can implement context
|
||||
* specific actions with the library, like running the query or updating some query in the current app.
|
||||
* @param options.context Used for tracking. Should identify the context this is called from, like 'explore' or
|
||||
* 'dashboard'.
|
||||
*/
|
||||
openDrawer: (datasourceFilters: string[], queryActionButton: QueryActionButton) => void;
|
||||
openDrawer: (
|
||||
datasourceFilters: string[],
|
||||
queryActionButton: QueryActionButton,
|
||||
options?: { context?: string }
|
||||
) => void;
|
||||
closeDrawer: () => void;
|
||||
isDrawerOpen: boolean;
|
||||
|
||||
/**
|
||||
* Opens a modal for adding a query to the library.
|
||||
* @param query Query to be saved
|
||||
* @param options.onSave Callback that will be called after the query is saved.
|
||||
* @param options.context Used for tracking. Should identify the context this is called from, like 'explore' or
|
||||
* 'dashboard'.
|
||||
*/
|
||||
openAddQueryModal: (query: DataQuery, options?: { onSave?: () => void; context?: string }) => void;
|
||||
closeAddQueryModal: () => void;
|
||||
|
||||
/**
|
||||
* Returns a predefined small button that can be used to save a query to the library.
|
||||
* @param query
|
||||
*/
|
||||
openAddQueryModal: (query: DataQuery) => void;
|
||||
closeAddQueryModal: () => void;
|
||||
renderSaveQueryButton: (query: DataQuery) => ReactNode;
|
||||
queryLibraryEnabled: boolean;
|
||||
};
|
||||
|
||||
export const QueryLibraryContext = createContext<QueryLibraryContextType>({
|
||||
@@ -39,80 +53,14 @@ export const QueryLibraryContext = createContext<QueryLibraryContextType>({
|
||||
|
||||
openAddQueryModal: () => {},
|
||||
closeAddQueryModal: () => {},
|
||||
|
||||
renderSaveQueryButton: () => {
|
||||
return null;
|
||||
},
|
||||
|
||||
queryLibraryEnabled: false,
|
||||
});
|
||||
|
||||
export function useQueryLibraryContext() {
|
||||
return useContext(QueryLibraryContext);
|
||||
}
|
||||
|
||||
export function QueryLibraryContextProvider({ children }: PropsWithChildren) {
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false);
|
||||
const [activeDatasources, setActiveDatasources] = useState<string[]>([]);
|
||||
const [isAddQueryModalOpen, setIsAddQueryModalOpen] = useState<boolean>(false);
|
||||
const [activeQuery, setActiveQuery] = useState<DataQuery | undefined>(undefined);
|
||||
const [queryActionButton, setQueryActionButton] = useState<QueryActionButton | undefined>(undefined);
|
||||
|
||||
const openDrawer = useCallback((datasourceFilters: string[], queryActionButton: QueryActionButton) => {
|
||||
setActiveDatasources(datasourceFilters);
|
||||
// Because the queryActionButton can be a function component it would be called as a callback if just passed in.
|
||||
setQueryActionButton(() => queryActionButton);
|
||||
setIsDrawerOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeDrawer = useCallback(() => {
|
||||
setActiveDatasources([]);
|
||||
setQueryActionButton(undefined);
|
||||
setIsDrawerOpen(false);
|
||||
}, []);
|
||||
|
||||
const openAddQueryModal = useCallback((query: DataQuery) => {
|
||||
setActiveQuery(query);
|
||||
setIsAddQueryModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeAddQueryModal = useCallback(() => {
|
||||
setActiveQuery(undefined);
|
||||
setIsAddQueryModalOpen(false);
|
||||
}, []);
|
||||
|
||||
// We wrap the action button one time to add the closeDrawer behaviour. This way whoever injects the action button
|
||||
// does not need to remember to do it nor the query table inside that renders it needs to know about the drawer.
|
||||
const finalActionButton = useMemo(() => {
|
||||
if (!queryActionButton) {
|
||||
return queryActionButton;
|
||||
}
|
||||
return (props: QueryActionButtonProps) => {
|
||||
const QButton = queryActionButton;
|
||||
return (
|
||||
<QButton
|
||||
{...props}
|
||||
onClick={() => {
|
||||
props.onClick();
|
||||
closeDrawer();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}, [closeDrawer, queryActionButton]);
|
||||
|
||||
return (
|
||||
<QueryLibraryContext.Provider
|
||||
value={{
|
||||
isDrawerOpen,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
openAddQueryModal,
|
||||
closeAddQueryModal,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<QueryLibraryDrawer
|
||||
isOpen={isDrawerOpen}
|
||||
close={closeDrawer}
|
||||
activeDatasources={activeDatasources}
|
||||
queryActionButton={finalActionButton}
|
||||
/>
|
||||
<AddToQueryLibraryModal isOpen={isAddQueryModalOpen} close={closeAddQueryModal} query={activeQuery} />
|
||||
</QueryLibraryContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query/react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { TabbedContainer, TabConfig } from '@grafana/ui';
|
||||
|
||||
import { t } from '../../../core/internationalization';
|
||||
import { useListQueryTemplateQuery } from '../../query-library';
|
||||
import { QUERY_LIBRARY_GET_LIMIT } from '../../query-library/api/api';
|
||||
import { ExploreDrawer } from '../ExploreDrawer';
|
||||
|
||||
import { QueryLibrary } from './QueryLibrary';
|
||||
import { QueryActionButton } from './types';
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
// List of datasource names to filter query templates by
|
||||
activeDatasources: string[] | undefined;
|
||||
close: () => void;
|
||||
queryActionButton?: QueryActionButton;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drawer with query library feature. Handles its own state and should be included in some top level component.
|
||||
*/
|
||||
export function QueryLibraryDrawer({ isOpen, activeDatasources, close, queryActionButton }: Props) {
|
||||
const { data } = useListQueryTemplateQuery(isOpen ? {} : skipToken);
|
||||
const queryTemplatesCount = data?.items?.length ?? 0;
|
||||
|
||||
// TODO: the tabbed container is here mainly for close button and some margins maybe make sense to use something
|
||||
// else as there is only one tab.
|
||||
const tabs: TabConfig[] = [
|
||||
{
|
||||
label: `${t('explore.rich-history.query-library', 'Query library')} (${queryTemplatesCount}/${QUERY_LIBRARY_GET_LIMIT})`,
|
||||
value: 'Query library',
|
||||
content: <QueryLibrary activeDatasources={activeDatasources} queryActionButton={queryActionButton} />,
|
||||
icon: 'book',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
isOpen && (
|
||||
<ExploreDrawer initialHeight={'75vh'}>
|
||||
<TabbedContainer
|
||||
tabs={tabs}
|
||||
onClose={close}
|
||||
defaultTab={'Query library'}
|
||||
closeIconTooltip={t('explore.rich-history.close-tooltip', 'Close query history')}
|
||||
testId={selectors.pages.Explore.QueryHistory.container}
|
||||
/>
|
||||
</ExploreDrawer>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Alert, Modal } from '@grafana/ui';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function QueryLibraryExpmInfo({ isOpen, onDismiss }: Props) {
|
||||
return (
|
||||
<Modal title="Query Library" isOpen={isOpen} onDismiss={onDismiss}>
|
||||
<Alert
|
||||
severity="info"
|
||||
title="Query library is in the experimental mode. It is a place where you can save your queries and share them with
|
||||
your team. Once you save a query, it will be available for the whole organization to use."
|
||||
/>
|
||||
<Alert severity="info" title=" Currently we are limiting the number of saved queries per organization to 1000." />
|
||||
<Alert
|
||||
severity="warning"
|
||||
title="Although it's unlikely, some data loss may occur during the experimental phase."
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { AppEvents, dateTime } from '@grafana/data';
|
||||
import { DataSourcePicker, getAppEvents, getDataSourceSrv } from '@grafana/runtime';
|
||||
import { DataQuery } from '@grafana/schema';
|
||||
import { Button, InlineSwitch, Modal, RadioButtonGroup, TextArea } from '@grafana/ui';
|
||||
import { Field } from '@grafana/ui/';
|
||||
import { Input } from '@grafana/ui/src/components/Input/Input';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { getQueryDisplayText } from 'app/core/utils/richHistory';
|
||||
import { useCreateQueryTemplateMutation, useUpdateQueryTemplateMutation } from 'app/features/query-library';
|
||||
import { AddQueryTemplateCommand, EditQueryTemplateCommand } from 'app/features/query-library/types';
|
||||
|
||||
import { convertAddQueryTemplateCommandToDataQuerySpec } from '../../query-library/api/mappers';
|
||||
import { useDatasource } from '../QueryLibrary/utils/useDatasource';
|
||||
|
||||
import { QueryTemplateRow } from './QueryTemplatesTable/types';
|
||||
|
||||
type Props = {
|
||||
onCancel: () => void;
|
||||
onSave: (isSuccess: boolean) => void;
|
||||
queryToAdd?: DataQuery;
|
||||
templateData?: QueryTemplateRow;
|
||||
};
|
||||
|
||||
export type QueryDetails = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
const getInstuctions = (isAdd: boolean) => {
|
||||
return isAdd
|
||||
? t(
|
||||
'explore.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.`
|
||||
)
|
||||
: t(
|
||||
'explore.query-template-modal.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.`
|
||||
);
|
||||
};
|
||||
|
||||
export const QueryTemplateForm = ({ onCancel, onSave, queryToAdd, templateData }: Props) => {
|
||||
const { register, handleSubmit } = useForm<QueryDetails>({
|
||||
defaultValues: {
|
||||
description: templateData?.description,
|
||||
},
|
||||
});
|
||||
|
||||
const [addQueryTemplate] = useCreateQueryTemplateMutation();
|
||||
const [editQueryTemplate] = useUpdateQueryTemplateMutation();
|
||||
|
||||
const datasource = useDatasource(queryToAdd?.datasource);
|
||||
|
||||
// this is an array to support multi query templates sometime in the future
|
||||
const queries =
|
||||
queryToAdd !== undefined ? [queryToAdd] : templateData?.query !== undefined ? [templateData?.query] : [];
|
||||
|
||||
const handleAddQueryTemplate = async (addQueryTemplateCommand: AddQueryTemplateCommand) => {
|
||||
return addQueryTemplate({
|
||||
queryTemplate: convertAddQueryTemplateCommandToDataQuerySpec(addQueryTemplateCommand),
|
||||
})
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
getAppEvents().publish({
|
||||
type: AppEvents.alertSuccess.name,
|
||||
payload: [t('explore.query-library.query-template-added', 'Query successfully saved to the library')],
|
||||
});
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
getAppEvents().publish({
|
||||
type: AppEvents.alertError.name,
|
||||
payload: [
|
||||
t('explore.query-library.query-template-add-error', 'Error attempting to save this query to the library'),
|
||||
],
|
||||
});
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditQueryTemplate = async (editQueryTemplateCommand: EditQueryTemplateCommand) => {
|
||||
return editQueryTemplate({
|
||||
name: editQueryTemplateCommand.uid,
|
||||
patch: {
|
||||
spec: editQueryTemplateCommand.partialSpec,
|
||||
},
|
||||
})
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
getAppEvents().publish({
|
||||
type: AppEvents.alertSuccess.name,
|
||||
payload: [t('explore.query-library.query-template-edited', 'Query template successfully edited')],
|
||||
});
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
getAppEvents().publish({
|
||||
type: AppEvents.alertError.name,
|
||||
payload: [t('explore.query-library.query-template-edit-error', 'Error attempting to edit this query')],
|
||||
});
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (data: QueryDetails) => {
|
||||
const timestamp = dateTime().toISOString();
|
||||
const temporaryDefaultTitle =
|
||||
data.description || t('explore.query-library.default-description', 'Public', { timestamp: timestamp });
|
||||
|
||||
if (templateData?.uid) {
|
||||
handleEditQueryTemplate({ uid: templateData.uid, partialSpec: { title: data.description } }).then((isSuccess) => {
|
||||
onSave(isSuccess);
|
||||
});
|
||||
} else if (queryToAdd) {
|
||||
handleAddQueryTemplate({ title: temporaryDefaultTitle, targets: [queryToAdd] }).then((isSuccess) => {
|
||||
onSave(isSuccess);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const { value: queryText } = useAsync(async () => {
|
||||
const promises = queries.map(async (query, i) => {
|
||||
const datasource = await getDataSourceSrv().get(query.datasource);
|
||||
return datasource?.getQueryDisplayText?.(query) || getQueryDisplayText(query);
|
||||
});
|
||||
return Promise.all(promises);
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<p>{getInstuctions(templateData === undefined)}</p>
|
||||
{queryText &&
|
||||
queryText.map((queryString, i) => (
|
||||
<Field key={`query-${i}`} label={t('explore.query-template-modal.query', 'Query')}>
|
||||
<TextArea readOnly={true} value={queryString}></TextArea>
|
||||
</Field>
|
||||
))}
|
||||
{queryToAdd && (
|
||||
<>
|
||||
<Field label={t('explore.query-template-modal.data-source-name', 'Data source name')}>
|
||||
<DataSourcePicker current={datasource?.uid} disabled={true} />
|
||||
</Field>
|
||||
<Field label={t('explore.query-template-modall.data-source-type', 'Data source type')}>
|
||||
<Input disabled={true} defaultValue={datasource?.meta.name}></Input>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field label={t('explore.query-template-modal.description', 'Description')}>
|
||||
<Input id="query-template-description" autoFocus={true} {...register('description')}></Input>
|
||||
</Field>
|
||||
<Field label={t('explore.query-template-modal.visibility', 'Visibility')}>
|
||||
<RadioButtonGroup
|
||||
options={[
|
||||
{ value: 'Public', label: t('explore.query-library.public', 'Public') },
|
||||
{ value: 'Private', label: t('explore.query-library.private', 'Private') },
|
||||
]}
|
||||
value={'Public'}
|
||||
disabled={true}
|
||||
/>
|
||||
</Field>
|
||||
<InlineSwitch
|
||||
showLabel={true}
|
||||
disabled={true}
|
||||
label={t(
|
||||
'explore.query-template-modal.auto-star',
|
||||
'Auto-star this query to add it to your starred list in the Query Library.'
|
||||
)}
|
||||
/>
|
||||
<Modal.ButtonRow>
|
||||
<Button variant="secondary" onClick={() => onCancel()} fill="outline">
|
||||
<Trans i18nKey="explore.query-library.cancel">Cancel</Trans>
|
||||
</Button>
|
||||
<Button variant="primary" type="submit">
|
||||
<Trans i18nKey="explore.query-library.save">Save</Trans>
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -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(<QueryTemplatesList />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/You haven't saved any queries to your library yet/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders query', async () => {
|
||||
data.items = testItems;
|
||||
render(<QueryTemplatesList />);
|
||||
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 <button>TEST_ACTION_BUTTON</button>;
|
||||
};
|
||||
|
||||
render(<QueryTemplatesList queryActionButton={queryActionButton} />);
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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<Array<SelectableValue<string>>>(
|
||||
props.activeDatasources?.map((ds) => ({ value: ds, label: ds })) || []
|
||||
);
|
||||
const [userFilters, setUserFilters] = useState<Array<SelectableValue<string>>>([]);
|
||||
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 (
|
||||
<EmptyState variant="not-found" message={`Something went wrong`}>
|
||||
{error.message}
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || loadUsersResult.isLoading || loadQueryMetadataResult.loading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<EmptyState message={`Query Library`} variant="not-found">
|
||||
<p>
|
||||
{
|
||||
"You haven't saved any queries to your library yet. Start adding them from Explore or your Query History tab."
|
||||
}
|
||||
</p>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<QueryLibraryExpmInfo isOpen={isModalOpen} onDismiss={() => setIsModalOpen(false)} />
|
||||
<Stack gap={0.5}>
|
||||
<FilterInput
|
||||
className={styles.searchInput}
|
||||
placeholder={t('query-library.search', 'Search by data source, query content or description')}
|
||||
aria-label={t('query-library.search', 'Search by data source, query content or description')}
|
||||
value={searchQuery}
|
||||
onChange={(query) => setSearchQuery(query)}
|
||||
escapeRegex={false}
|
||||
/>
|
||||
<InlineLabel className={styles.label} width="auto">
|
||||
<Trans i18nKey="query-library.datasource-names">Datasource name(s):</Trans>
|
||||
</InlineLabel>
|
||||
<MultiSelect
|
||||
className={styles.multiSelect}
|
||||
onChange={(items, actionMeta) => {
|
||||
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)'}
|
||||
/>
|
||||
<InlineLabel className={styles.label} width="auto">
|
||||
<Trans i18nKey="query-library.user-names">User name(s):</Trans>
|
||||
</InlineLabel>
|
||||
<MultiSelect
|
||||
isLoading={loadUsersResult.isLoading}
|
||||
className={styles.multiSelect}
|
||||
onChange={(items, actionMeta) => {
|
||||
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)'}
|
||||
/>
|
||||
<Badge
|
||||
text=""
|
||||
icon="info"
|
||||
aria-label="info"
|
||||
tooltip={'Click here for more informationn about Query library'}
|
||||
color="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
/>
|
||||
</Stack>
|
||||
<QueryTemplatesTable queryTemplateRows={filteredRows} queryActionButton={props.queryActionButton} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<typeof useLoadUsers>['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}`,
|
||||
}),
|
||||
});
|
||||
@@ -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 (
|
||||
<div className={styles.cell}>
|
||||
<IconButton
|
||||
className={styles.actionButton}
|
||||
size="lg"
|
||||
name="trash-alt"
|
||||
title={t('explore.query-library.delete-query', 'Delete query')}
|
||||
tooltip={t('explore.query-library.delete-query', 'Delete query')}
|
||||
onClick={() => {
|
||||
if (queryUid) {
|
||||
onDeleteQuery(queryUid);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
className={styles.actionButton}
|
||||
size="lg"
|
||||
name="comment-alt"
|
||||
title={t('explore.query-library.add-edit-description', 'Add/edit description')}
|
||||
tooltip={t('explore.query-library.add-edit-description', 'Add/edit description')}
|
||||
onClick={() => {
|
||||
setEditFormOpen(true);
|
||||
queryLibraryTrackAddOrEditDescription();
|
||||
}}
|
||||
/>
|
||||
{QueryActionButton && (
|
||||
<QueryActionButton
|
||||
queries={queryTemplate.query ? [queryTemplate.query] : []}
|
||||
datasourceUid={rootDatasourceUid}
|
||||
onClick={() => {
|
||||
queryLibraryTrackRunQuery(queryTemplate.datasourceType || '');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Modal
|
||||
title={t('explore.query-template-modal.edit-title', 'Edit query')}
|
||||
isOpen={editFormOpen}
|
||||
onDismiss={() => setEditFormOpen(false)}
|
||||
>
|
||||
<QueryTemplateForm
|
||||
onCancel={() => setEditFormOpen(false)}
|
||||
templateData={queryTemplate}
|
||||
onSave={() => {
|
||||
setEditFormOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActionsCell;
|
||||
@@ -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 (
|
||||
<div>
|
||||
<span className={styles.logo}>
|
||||
<Avatar src={props.user?.avatarUrl || 'https://secure.gravatar.com/avatar'} alt="unknown" />
|
||||
</span>
|
||||
<span className={styles.otherText}>{props.user?.displayName || 'Unknown'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user