Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync

This commit is contained in:
Mariell Hoversholm committed 2025-02-07 14:14:54 +01:00
commit 4d732cb005
118 files changed
+1883 -3417

No files matched your search

-21
View File
@@ -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"]
],
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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))
+2 -2
View File
@@ -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,
+41 -11
View File
@@ -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
}
+45 -5
View File
@@ -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
View File
@@ -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 != "" {
+19 -1
View File
@@ -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
}
+31
View File
@@ -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;`
+4 -3
View File
@@ -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.
+48 -6
View File
@@ -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 {
+8 -2
View File
@@ -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 {
+3 -2
View File
@@ -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
}
+10 -1
View File
@@ -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
},
}
}
+8 -2
View File
@@ -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)
+6
View File
@@ -1794,6 +1794,12 @@ var (
HideFromDocs: true,
RequiresRestart: true,
},
{
Name: "pluginsCDNSyncLoader",
Description: "Load plugins from CDN synchronously",
Stage: FeatureStageExperimental,
Owner: grafanaPluginsPlatformSquad,
},
}
)
+1
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
238 alertingAlertmanagerExtraDedupStageStopPipeline experimental @grafana/alerting-squad false true false
239 newLogsPanel experimental @grafana/observability-logs false false true
240 grafanaconThemes experimental @grafana/grafana-frontend-platform false true false
241 pluginsCDNSyncLoader experimental @grafana/plugins-platform-backend false false false
+4
View File
@@ -962,4 +962,8 @@ const (
// FlagGrafanaconThemes
// Enables the temporary themes for GrafanaCon
FlagGrafanaconThemes = "grafanaconThemes"
// FlagPluginsCDNSyncLoader
// Load plugins from CDN synchronously
FlagPluginsCDNSyncLoader = "pluginsCDNSyncLoader"
)
+12
View File
@@ -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())
}
+56 -137
View File
@@ -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
+21
View File
@@ -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"
}
}
}
-17
View File
@@ -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
+21
View File
@@ -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"
}
}
-17
View File
@@ -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
+21
View File
@@ -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"
}
}
-17
View File
@@ -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
+22
View File
@@ -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"
}
}
-14
View File
@@ -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
+7 -1
View File
@@ -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!)) {
@@ -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());
}
}
@@ -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 {
@@ -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,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)}
/>
);
}
@@ -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',
});
@@ -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();
}
}
}
@@ -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} />;
}
@@ -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],
@@ -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,
},
},
}),
};
};
@@ -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,
},
},
}),
};
};
@@ -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,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(
@@ -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>
@@ -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>
);
@@ -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',
}),
};
}
@@ -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()} />;
}
@@ -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',
}),
};
}
@@ -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} />;
}
@@ -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}
/>
);
}
@@ -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,
}),
};
}
@@ -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;
}
@@ -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