diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b0734bb0a4c..5e4e7a4b523 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -787,7 +787,6 @@ embed.go @grafana/grafana-as-code /.github/workflows/publish-technical-documentation-next.yml @grafana/docs-tooling /.github/workflows/publish-technical-documentation-release.yml @grafana/docs-tooling /.github/workflows/remove-milestone.yml @grafana/grafana-developer-enablement-squad -/.github/workflows/sbom-report.yml @grafana/security-team /.github/workflows/scripts/json-file-to-job-output.js @grafana/plugins-platform-frontend /.github/workflows/stale.yml @grafana/grafana-developer-enablement-squad /.github/workflows/update-changelog.yml @grafana/grafana-developer-enablement-squad diff --git a/.github/workflows/sbom-report.yml b/.github/workflows/sbom-report.yml deleted file mode 100644 index efd188da331..00000000000 --- a/.github/workflows/sbom-report.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: syft-sbom-ci -on: - release: - types: [ published ] - workflow_dispatch: -jobs: - syft-sbom: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Anchore SBOM Action - uses: grafana/shared-workflows/actions@syft-sbom-v0.0.1 - with: - artifact-name: ${{ github.event.repository.name }}-spdx.json diff --git a/apps/advisor/kinds/check.cue b/apps/advisor/kinds/check.cue index a564f888e2f..7ae338e888e 100644 --- a/apps/advisor/kinds/check.cue +++ b/apps/advisor/kinds/check.cue @@ -21,23 +21,23 @@ check: { // Generic data input that a check can receive data?: [string]: string } - #ReportError: { - // Severity of the error + #ReportFailure: { + // Severity of the failure severity: "high" | "low" - // Human readable reason for the error + // Human readable reason for the failure reason: string - // Action to take to resolve the error + // Action to take to resolve the failure action: string - // Step ID that the error is associated with + // Step ID that the failure is associated with stepID: string - // Item ID that the error is associated with + // Item ID that the failure is associated with itemID: string } #Report: { // Number of elements analyzed count: int - // List of errors - errors: [...#ReportError] + // List of failures + failures: [...#ReportFailure] } spec: #Data status: { diff --git a/apps/advisor/pkg/apis/advisor/v0alpha1/check_status_gen.go b/apps/advisor/pkg/apis/advisor/v0alpha1/check_status_gen.go index edc67575fe8..342751c6ac3 100644 --- a/apps/advisor/pkg/apis/advisor/v0alpha1/check_status_gen.go +++ b/apps/advisor/pkg/apis/advisor/v0alpha1/check_status_gen.go @@ -3,22 +3,22 @@ package v0alpha1 // +k8s:openapi-gen=true -type CheckReportError struct { - // Severity of the error - Severity CheckReportErrorSeverity `json:"severity"` - // Human readable reason for the error +type CheckReportFailure struct { + // Severity of the failure + Severity CheckReportFailureSeverity `json:"severity"` + // Human readable reason for the failure Reason string `json:"reason"` - // Action to take to resolve the error + // Action to take to resolve the failure Action string `json:"action"` - // Step ID that the error is associated with + // Step ID that the failure is associated with StepID string `json:"stepID"` - // Item ID that the error is associated with + // Item ID that the failure is associated with ItemID string `json:"itemID"` } -// NewCheckReportError creates a new CheckReportError object. -func NewCheckReportError() *CheckReportError { - return &CheckReportError{} +// NewCheckReportFailure creates a new CheckReportFailure object. +func NewCheckReportFailure() *CheckReportFailure { + return &CheckReportFailure{} } // +k8s:openapi-gen=true @@ -57,11 +57,11 @@ func NewCheckStatus() *CheckStatus { } // +k8s:openapi-gen=true -type CheckReportErrorSeverity string +type CheckReportFailureSeverity string const ( - CheckReportErrorSeverityHigh CheckReportErrorSeverity = "high" - CheckReportErrorSeverityLow CheckReportErrorSeverity = "low" + CheckReportFailureSeverityHigh CheckReportFailureSeverity = "high" + CheckReportFailureSeverityLow CheckReportFailureSeverity = "low" ) // +k8s:openapi-gen=true @@ -77,8 +77,8 @@ const ( type CheckV0alpha1StatusReport struct { // Number of elements analyzed Count int64 `json:"count"` - // List of errors - Errors []CheckReportError `json:"errors"` + // List of failures + Failures []CheckReportFailure `json:"failures"` } // NewCheckV0alpha1StatusReport creates a new CheckV0alpha1StatusReport object. diff --git a/apps/advisor/pkg/apis/advisor/v0alpha1/zz_openapi_gen.go b/apps/advisor/pkg/apis/advisor/v0alpha1/zz_openapi_gen.go index 10a2de58e84..6f84fa9ef4c 100644 --- a/apps/advisor/pkg/apis/advisor/v0alpha1/zz_openapi_gen.go +++ b/apps/advisor/pkg/apis/advisor/v0alpha1/zz_openapi_gen.go @@ -14,7 +14,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA return map[string]common.OpenAPIDefinition{ "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.Check": schema_pkg_apis_advisor_v0alpha1_Check(ref), "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckList": schema_pkg_apis_advisor_v0alpha1_CheckList(ref), - "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReportError": schema_pkg_apis_advisor_v0alpha1_CheckReportError(ref), + "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReportFailure": schema_pkg_apis_advisor_v0alpha1_CheckReportFailure(ref), "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckSpec": schema_pkg_apis_advisor_v0alpha1_CheckSpec(ref), "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckStatus": schema_pkg_apis_advisor_v0alpha1_CheckStatus(ref), "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckType": schema_pkg_apis_advisor_v0alpha1_CheckType(ref), @@ -123,7 +123,7 @@ func schema_pkg_apis_advisor_v0alpha1_CheckList(ref common.ReferenceCallback) co } } -func schema_pkg_apis_advisor_v0alpha1_CheckReportError(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_advisor_v0alpha1_CheckReportFailure(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -131,7 +131,7 @@ func schema_pkg_apis_advisor_v0alpha1_CheckReportError(ref common.ReferenceCallb Properties: map[string]spec.Schema{ "severity": { SchemaProps: spec.SchemaProps{ - Description: "Severity of the error", + Description: "Severity of the failure", Default: "", Type: []string{"string"}, Format: "", @@ -139,7 +139,7 @@ func schema_pkg_apis_advisor_v0alpha1_CheckReportError(ref common.ReferenceCallb }, "reason": { SchemaProps: spec.SchemaProps{ - Description: "Human readable reason for the error", + Description: "Human readable reason for the failure", Default: "", Type: []string{"string"}, Format: "", @@ -147,7 +147,7 @@ func schema_pkg_apis_advisor_v0alpha1_CheckReportError(ref common.ReferenceCallb }, "action": { SchemaProps: spec.SchemaProps{ - Description: "Action to take to resolve the error", + Description: "Action to take to resolve the failure", Default: "", Type: []string{"string"}, Format: "", @@ -155,7 +155,7 @@ func schema_pkg_apis_advisor_v0alpha1_CheckReportError(ref common.ReferenceCallb }, "stepID": { SchemaProps: spec.SchemaProps{ - Description: "Step ID that the error is associated with", + Description: "Step ID that the failure is associated with", Default: "", Type: []string{"string"}, Format: "", @@ -163,7 +163,7 @@ func schema_pkg_apis_advisor_v0alpha1_CheckReportError(ref common.ReferenceCallb }, "itemID": { SchemaProps: spec.SchemaProps{ - Description: "Item ID that the error is associated with", + Description: "Item ID that the failure is associated with", Default: "", Type: []string{"string"}, Format: "", @@ -528,26 +528,26 @@ func schema_pkg_apis_advisor_v0alpha1_CheckV0alpha1StatusReport(ref common.Refer Format: "int64", }, }, - "errors": { + "failures": { SchemaProps: spec.SchemaProps{ - Description: "List of errors", + Description: "List of failures", Type: []string{"array"}, Items: &spec.SchemaOrArray{ Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReportError"), + Ref: ref("github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReportFailure"), }, }, }, }, }, }, - Required: []string{"count", "errors"}, + Required: []string{"count", "failures"}, }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReportError"}, + "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReportFailure"}, } } diff --git a/apps/advisor/pkg/apis/advisor_manifest.go b/apps/advisor/pkg/apis/advisor_manifest.go index 8b39d626436..4048f4f687a 100644 --- a/apps/advisor/pkg/apis/advisor_manifest.go +++ b/apps/advisor/pkg/apis/advisor_manifest.go @@ -12,7 +12,7 @@ import ( ) var ( - rawSchemaCheckv0alpha1 = []byte(`{"spec":{"properties":{"data":{"additionalProperties":{"type":"string"},"description":"Generic data input that a check can receive","type":"object"}},"type":"object"},"status":{"properties":{"additionalFields":{"description":"additionalFields is reserved for future use","type":"object","x-kubernetes-preserve-unknown-fields":true},"operatorStates":{"additionalProperties":{"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"description":"details contains any extra information that is operator-specific","type":"object","x-kubernetes-preserve-unknown-fields":true},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"},"report":{"properties":{"count":{"description":"Number of elements analyzed","type":"integer"},"errors":{"description":"List of errors","items":{"properties":{"action":{"description":"Action to take to resolve the error","type":"string"},"itemID":{"description":"Item ID that the error is associated with","type":"string"},"reason":{"description":"Human readable reason for the error","type":"string"},"severity":{"description":"Severity of the error","enum":["high","low"],"type":"string"},"stepID":{"description":"Step ID that the error is associated with","type":"string"}},"required":["severity","reason","action","stepID","itemID"],"type":"object"},"type":"array"}},"required":["count","errors"],"type":"object"}},"required":["report"],"type":"object","x-kubernetes-preserve-unknown-fields":true}}`) + rawSchemaCheckv0alpha1 = []byte(`{"spec":{"properties":{"data":{"additionalProperties":{"type":"string"},"description":"Generic data input that a check can receive","type":"object"}},"type":"object"},"status":{"properties":{"additionalFields":{"description":"additionalFields is reserved for future use","type":"object","x-kubernetes-preserve-unknown-fields":true},"operatorStates":{"additionalProperties":{"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"description":"details contains any extra information that is operator-specific","type":"object","x-kubernetes-preserve-unknown-fields":true},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"},"report":{"properties":{"count":{"description":"Number of elements analyzed","type":"integer"},"failures":{"description":"List of failures","items":{"properties":{"action":{"description":"Action to take to resolve the failure","type":"string"},"itemID":{"description":"Item ID that the failure is associated with","type":"string"},"reason":{"description":"Human readable reason for the failure","type":"string"},"severity":{"description":"Severity of the failure","enum":["high","low"],"type":"string"},"stepID":{"description":"Step ID that the failure is associated with","type":"string"}},"required":["severity","reason","action","stepID","itemID"],"type":"object"},"type":"array"}},"required":["count","failures"],"type":"object"}},"required":["report"],"type":"object","x-kubernetes-preserve-unknown-fields":true}}`) versionSchemaCheckv0alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaCheckv0alpha1, &versionSchemaCheckv0alpha1) rawSchemaCheckTypev0alpha1 = []byte(`{"spec":{"properties":{"name":{"type":"string"},"steps":{"items":{"properties":{"description":{"type":"string"},"stepID":{"type":"string"},"title":{"type":"string"}},"required":["title","description","stepID"],"type":"object"},"type":"array"}},"required":["name","steps"],"type":"object"},"status":{"properties":{"additionalFields":{"description":"additionalFields is reserved for future use","type":"object","x-kubernetes-preserve-unknown-fields":true},"operatorStates":{"additionalProperties":{"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"description":"details contains any extra information that is operator-specific","type":"object","x-kubernetes-preserve-unknown-fields":true},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object","x-kubernetes-preserve-unknown-fields":true}}`) diff --git a/apps/advisor/pkg/app/checks/datasourcecheck/check.go b/apps/advisor/pkg/app/checks/datasourcecheck/check.go index e946f2e2819..ff11740c62e 100644 --- a/apps/advisor/pkg/app/checks/datasourcecheck/check.go +++ b/apps/advisor/pkg/app/checks/datasourcecheck/check.go @@ -75,7 +75,7 @@ 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, i any) (*advisor.CheckReportError, error) { +func (s *uidValidationStep) Run(ctx context.Context, obj *advisor.CheckSpec, i any) (*advisor.CheckReportFailure, error) { ds, ok := i.(*datasources.DataSource) if !ok { return nil, fmt.Errorf("invalid item type %T", i) @@ -83,8 +83,8 @@ func (s *uidValidationStep) Run(ctx context.Context, obj *advisor.CheckSpec, i a // Data source UID validation err := util.ValidateUID(ds.UID) if err != nil { - return checks.NewCheckReportError( - advisor.CheckReportErrorSeverityLow, + return checks.NewCheckReportFailure( + advisor.CheckReportFailureSeverityLow, fmt.Sprintf("Invalid UID '%s' for data source %s", ds.UID, ds.Name), "Check the documentation for more information.", s.ID(), @@ -111,7 +111,7 @@ func (s *healthCheckStep) ID() string { return "health-check" } -func (s *healthCheckStep) Run(ctx context.Context, obj *advisor.CheckSpec, i any) (*advisor.CheckReportError, error) { +func (s *healthCheckStep) Run(ctx context.Context, obj *advisor.CheckSpec, i any) (*advisor.CheckReportFailure, error) { ds, ok := i.(*datasources.DataSource) if !ok { return nil, fmt.Errorf("invalid item type %T", i) @@ -132,8 +132,8 @@ func (s *healthCheckStep) Run(ctx context.Context, obj *advisor.CheckSpec, i any } resp, err := s.PluginClient.CheckHealth(ctx, req) if err != nil || resp.Status != backend.HealthStatusOk { - return checks.NewCheckReportError( - advisor.CheckReportErrorSeverityHigh, + return checks.NewCheckReportFailure( + advisor.CheckReportFailureSeverityHigh, fmt.Sprintf("Health check failed for %s", ds.Name), fmt.Sprintf( "Go to the data source configuration"+ diff --git a/apps/advisor/pkg/app/checks/datasourcecheck/check_test.go b/apps/advisor/pkg/app/checks/datasourcecheck/check_test.go index 5cfe595de67..c71122efc1b 100644 --- a/apps/advisor/pkg/app/checks/datasourcecheck/check_test.go +++ b/apps/advisor/pkg/app/checks/datasourcecheck/check_test.go @@ -14,7 +14,7 @@ import ( ) func TestCheck_Run(t *testing.T) { - t.Run("should return no errors when all datasources are healthy", func(t *testing.T) { + t.Run("should return no failures when all datasources are healthy", func(t *testing.T) { datasources := []*datasources.DataSource{ {UID: "valid-uid-1", Type: "prometheus", Name: "Prometheus"}, {UID: "valid-uid-2", Type: "mysql", Name: "MySQL"}, @@ -33,23 +33,23 @@ func TestCheck_Run(t *testing.T) { ctx := identity.WithRequester(context.Background(), &user.SignedInUser{}) items, err := check.Items(ctx) assert.NoError(t, err) - errs := []advisor.CheckReportError{} + failures := []advisor.CheckReportFailure{} for _, step := range check.Steps() { for _, item := range items { - stepErr, err := step.Run(ctx, &advisor.CheckSpec{}, item) + stepFailures, err := step.Run(ctx, &advisor.CheckSpec{}, item) assert.NoError(t, err) - if stepErr != nil { - errs = append(errs, *stepErr) + if stepFailures != nil { + failures = append(failures, *stepFailures) } } } assert.NoError(t, err) assert.Equal(t, 2, len(items)) - assert.Empty(t, errs) + assert.Empty(t, failures) }) - t.Run("should return errors when datasource UID is invalid", func(t *testing.T) { + t.Run("should return failures when datasource UID is invalid", func(t *testing.T) { datasources := []*datasources.DataSource{ {UID: "invalid uid", Type: "prometheus", Name: "Prometheus"}, } @@ -67,24 +67,24 @@ func TestCheck_Run(t *testing.T) { ctx := identity.WithRequester(context.Background(), &user.SignedInUser{}) items, err := check.Items(ctx) assert.NoError(t, err) - errs := []advisor.CheckReportError{} + failures := []advisor.CheckReportFailure{} for _, step := range check.Steps() { for _, item := range items { - stepErr, err := step.Run(ctx, &advisor.CheckSpec{}, item) + stepFailures, err := step.Run(ctx, &advisor.CheckSpec{}, item) assert.NoError(t, err) - if stepErr != nil { - errs = append(errs, *stepErr) + if stepFailures != nil { + failures = append(failures, *stepFailures) } } } assert.NoError(t, err) assert.Equal(t, 1, len(items)) - assert.Len(t, errs, 1) - assert.Equal(t, "Invalid UID 'invalid uid' for data source Prometheus", errs[0].Reason) + assert.Len(t, failures, 1) + assert.Equal(t, "Invalid UID 'invalid uid' for data source Prometheus", failures[0].Reason) }) - t.Run("should return errors when datasource health check fails", func(t *testing.T) { + t.Run("should return failures when datasource health check fails", func(t *testing.T) { datasources := []*datasources.DataSource{ {UID: "valid-uid-1", Type: "prometheus", Name: "Prometheus"}, } @@ -102,21 +102,21 @@ func TestCheck_Run(t *testing.T) { ctx := identity.WithRequester(context.Background(), &user.SignedInUser{}) items, err := check.Items(ctx) assert.NoError(t, err) - errs := []advisor.CheckReportError{} + failures := []advisor.CheckReportFailure{} for _, step := range check.Steps() { for _, item := range items { - stepErr, err := step.Run(ctx, &advisor.CheckSpec{}, item) + stepFailures, err := step.Run(ctx, &advisor.CheckSpec{}, item) assert.NoError(t, err) - if stepErr != nil { - errs = append(errs, *stepErr) + if stepFailures != nil { + failures = append(failures, *stepFailures) } } } assert.NoError(t, err) assert.Equal(t, 1, len(items)) - assert.Len(t, errs, 1) - assert.Equal(t, "Health check failed for Prometheus", errs[0].Reason) + assert.Len(t, failures, 1) + assert.Equal(t, "Health check failed for Prometheus", failures[0].Reason) }) } diff --git a/apps/advisor/pkg/app/checks/ifaces.go b/apps/advisor/pkg/app/checks/ifaces.go index 915d986a4a2..7972baf3c57 100644 --- a/apps/advisor/pkg/app/checks/ifaces.go +++ b/apps/advisor/pkg/app/checks/ifaces.go @@ -25,5 +25,5 @@ type Step interface { // Description returns the description of the step Description() string // Run executes the step for an item and returns a report - Run(ctx context.Context, obj *advisorv0alpha1.CheckSpec, item any) (*advisorv0alpha1.CheckReportError, error) + Run(ctx context.Context, obj *advisorv0alpha1.CheckSpec, item any) (*advisorv0alpha1.CheckReportFailure, error) } diff --git a/apps/advisor/pkg/app/checks/plugincheck/check.go b/apps/advisor/pkg/app/checks/plugincheck/check.go index f9dc853d0b8..40b4328d9e0 100644 --- a/apps/advisor/pkg/app/checks/plugincheck/check.go +++ b/apps/advisor/pkg/app/checks/plugincheck/check.go @@ -78,7 +78,7 @@ func (s *deprecationStep) ID() string { return "deprecation" } -func (s *deprecationStep) Run(ctx context.Context, _ *advisor.CheckSpec, it any) (*advisor.CheckReportError, error) { +func (s *deprecationStep) Run(ctx context.Context, _ *advisor.CheckSpec, it any) (*advisor.CheckReportFailure, error) { p, ok := it.(pluginstore.Plugin) if !ok { return nil, fmt.Errorf("invalid item type %T", it) @@ -96,8 +96,8 @@ func (s *deprecationStep) Run(ctx context.Context, _ *advisor.CheckSpec, it any) return nil, nil } if i.Status == "deprecated" { - return checks.NewCheckReportError( - advisor.CheckReportErrorSeverityHigh, + return checks.NewCheckReportFailure( + advisor.CheckReportFailureSeverityHigh, fmt.Sprintf("Plugin deprecated: %s", p.ID), "Check the documentation for recommended steps.", s.ID(), @@ -125,7 +125,7 @@ func (s *updateStep) ID() string { return "update" } -func (s *updateStep) Run(ctx context.Context, _ *advisor.CheckSpec, i any) (*advisor.CheckReportError, error) { +func (s *updateStep) Run(ctx context.Context, _ *advisor.CheckSpec, i any) (*advisor.CheckReportFailure, error) { p, ok := i.(pluginstore.Plugin) if !ok { return nil, fmt.Errorf("invalid item type %T", i) @@ -149,8 +149,8 @@ func (s *updateStep) Run(ctx context.Context, _ *advisor.CheckSpec, i any) (*adv return nil, nil } if hasUpdate(p, info) { - return checks.NewCheckReportError( - advisor.CheckReportErrorSeverityLow, + return checks.NewCheckReportFailure( + advisor.CheckReportFailureSeverityLow, fmt.Sprintf("New version available for %s", p.ID), fmt.Sprintf( "Go to the plugin admin page"+ diff --git a/apps/advisor/pkg/app/checks/plugincheck/check_test.go b/apps/advisor/pkg/app/checks/plugincheck/check_test.go index ff5190278e2..1013d9818d1 100644 --- a/apps/advisor/pkg/app/checks/plugincheck/check_test.go +++ b/apps/advisor/pkg/app/checks/plugincheck/check_test.go @@ -21,12 +21,12 @@ func TestRun(t *testing.T) { pluginArchives map[string]*repo.PluginArchiveInfo pluginPreinstalled []string pluginManaged []string - expectedErrors []advisor.CheckReportError + expectedFailures []advisor.CheckReportFailure }{ { - name: "No plugins", - plugins: []pluginstore.Plugin{}, - expectedErrors: []advisor.CheckReportError{}, + name: "No plugins", + plugins: []pluginstore.Plugin{}, + expectedFailures: []advisor.CheckReportFailure{}, }, { name: "Deprecated plugin", @@ -39,9 +39,9 @@ func TestRun(t *testing.T) { pluginArchives: map[string]*repo.PluginArchiveInfo{ "plugin1": {Version: "1.0.0"}, }, - expectedErrors: []advisor.CheckReportError{ + expectedFailures: []advisor.CheckReportFailure{ { - Severity: advisor.CheckReportErrorSeverityHigh, + Severity: advisor.CheckReportFailureSeverityHigh, Reason: "Plugin deprecated: plugin1", Action: "Check the documentation for recommended steps.", StepID: "deprecation", @@ -60,9 +60,9 @@ func TestRun(t *testing.T) { pluginArchives: map[string]*repo.PluginArchiveInfo{ "plugin2": {Version: "1.1.0"}, }, - expectedErrors: []advisor.CheckReportError{ + expectedFailures: []advisor.CheckReportFailure{ { - Severity: advisor.CheckReportErrorSeverityLow, + Severity: advisor.CheckReportFailureSeverityLow, Reason: "New version available for plugin2", Action: "Go to the plugin admin page and upgrade to the latest version.", StepID: "update", @@ -81,9 +81,9 @@ func TestRun(t *testing.T) { pluginArchives: map[string]*repo.PluginArchiveInfo{ "plugin2": {Version: "beta"}, }, - expectedErrors: []advisor.CheckReportError{ + expectedFailures: []advisor.CheckReportFailure{ { - Severity: advisor.CheckReportErrorSeverityLow, + Severity: advisor.CheckReportFailureSeverityLow, Reason: "New version available for plugin2", Action: "Go to the plugin admin page and upgrade to the latest version.", StepID: "update", @@ -103,7 +103,7 @@ func TestRun(t *testing.T) { "plugin3": {Version: "1.1.0"}, }, pluginPreinstalled: []string{"plugin3"}, - expectedErrors: []advisor.CheckReportError{}, + expectedFailures: []advisor.CheckReportFailure{}, }, { name: "Managed plugin", @@ -116,8 +116,8 @@ func TestRun(t *testing.T) { pluginArchives: map[string]*repo.PluginArchiveInfo{ "plugin4": {Version: "1.1.0"}, }, - pluginManaged: []string{"plugin4"}, - expectedErrors: []advisor.CheckReportError{}, + pluginManaged: []string{"plugin4"}, + expectedFailures: []advisor.CheckReportFailure{}, }, } @@ -134,19 +134,19 @@ func TestRun(t *testing.T) { items, err := check.Items(context.Background()) assert.NoError(t, err) - errs := []advisor.CheckReportError{} + failures := []advisor.CheckReportFailure{} for _, step := range check.Steps() { for _, item := range items { - stepErr, err := step.Run(context.Background(), &advisor.CheckSpec{}, item) + stepFailures, err := step.Run(context.Background(), &advisor.CheckSpec{}, item) assert.NoError(t, err) - if stepErr != nil { - errs = append(errs, *stepErr) + if stepFailures != nil { + failures = append(failures, *stepFailures) } } } assert.NoError(t, err) assert.Equal(t, len(tt.plugins), len(items)) - assert.Equal(t, tt.expectedErrors, errs) + assert.Equal(t, tt.expectedFailures, failures) }) } } diff --git a/apps/advisor/pkg/app/checks/utils.go b/apps/advisor/pkg/app/checks/utils.go index 053dc591389..ee33d57076b 100644 --- a/apps/advisor/pkg/app/checks/utils.go +++ b/apps/advisor/pkg/app/checks/utils.go @@ -4,14 +4,14 @@ import ( advisor "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1" ) -func NewCheckReportError( - severity advisor.CheckReportErrorSeverity, +func NewCheckReportFailure( + severity advisor.CheckReportFailureSeverity, reason string, action string, stepID string, itemID string, -) *advisor.CheckReportError { - return &advisor.CheckReportError{ +) *advisor.CheckReportFailure { + return &advisor.CheckReportFailure{ Severity: severity, Reason: reason, Action: action, diff --git a/apps/advisor/pkg/app/utils.go b/apps/advisor/pkg/app/utils.go index b1809dd2ee3..731d16e8737 100644 --- a/apps/advisor/pkg/app/utils.go +++ b/apps/advisor/pkg/app/utils.go @@ -84,7 +84,7 @@ func processCheck(ctx context.Context, client resource.Client, obj resource.Obje } // Run the steps steps := check.Steps() - reportErrors, err := runStepsInParallel(ctx, &c.Spec, steps, items) + failures, err := runStepsInParallel(ctx, &c.Spec, steps, items) if err != nil { setErr := setStatusAnnotation(ctx, client, obj, "error") if setErr != nil { @@ -94,8 +94,8 @@ func processCheck(ctx context.Context, client resource.Client, obj resource.Obje } report := &advisorv0alpha1.CheckV0alpha1StatusReport{ - Errors: reportErrors, - Count: int64(len(items)), + Failures: failures, + Count: int64(len(items)), } err = setStatusAnnotation(ctx, client, obj, "processed") if err != nil { @@ -110,8 +110,8 @@ 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{} +func runStepsInParallel(ctx context.Context, spec *advisorv0alpha1.CheckSpec, steps []checks.Step, items []any) ([]advisorv0alpha1.CheckReportFailure, error) { + reportFailures := []advisorv0alpha1.CheckReportFailure{} var internalErr error var wg sync.WaitGroup var mu sync.Mutex @@ -133,11 +133,11 @@ func runStepsInParallel(ctx context.Context, spec *advisorv0alpha1.CheckSpec, st return } if stepErr != nil { - reportErrs = append(reportErrs, *stepErr) + reportFailures = append(reportFailures, *stepErr) } }(step, item) } } wg.Wait() - return reportErrs, internalErr + return reportFailures, internalErr } diff --git a/apps/advisor/pkg/app/utils_test.go b/apps/advisor/pkg/app/utils_test.go index 5905e2a9c44..ada844fdba4 100644 --- a/apps/advisor/pkg/app/utils_test.go +++ b/apps/advisor/pkg/app/utils_test.go @@ -105,7 +105,7 @@ func TestProcessMultipleCheckItems(t *testing.T) { 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) + assert.Len(t, r.Failures, 50) } func TestProcessCheck_AlreadyProcessed(t *testing.T) { @@ -173,12 +173,12 @@ 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.CheckReportFailure, error) { if m.err != nil { return nil, m.err } if _, ok := items.(error); ok { - return &advisorv0alpha1.CheckReportError{}, nil + return &advisorv0alpha1.CheckReportFailure{}, nil } return nil, nil } diff --git a/e2e/cypress/plugins/smtpTester.js b/e2e/cypress/plugins/smtpTester.js index 67a8798a6aa..b1a7a4e1a4c 100644 --- a/e2e/cypress/plugins/smtpTester.js +++ b/e2e/cypress/plugins/smtpTester.js @@ -1,3 +1,6 @@ +const fs = require('fs'); +const { Jimp, diff } = require('jimp'); +const pdf = require('pdf-parse'); const ms = require('smtp-tester'); const PORT = 7777; @@ -29,6 +32,97 @@ const initialize = (on, config) => { return lastEmail[email] || null; }, }); + + on('task', { + async compareImages({ expectedImageFilepath, newImageContent, updateExpectedImage = false }) { + const inputBuffer = Buffer.from(newImageContent.data); + if (updateExpectedImage) { + fs.writeFileSync(expectedImageFilepath, inputBuffer); + return true; + } + + const inputImage = await Jimp.read(inputBuffer); + const expectedImage = await Jimp.read(expectedImageFilepath); + + const pixelDiff = diff(expectedImage, inputImage, 0.3); + return pixelDiff.percent <= 0.1; + }, + }); + + on('task', { + async compareCSVs({ expectedCSVFilepath, newCSVContent, updateExpectedCSV = false }) { + const inputBuffer = Buffer.from(newCSVContent.data); + if (updateExpectedCSV) { + await fs.writeFileSync(expectedCSVFilepath, inputBuffer); + return true; + } + + const inputCSV = toCSV(inputBuffer); + const expectedCSV = toCSV(fs.readFileSync(expectedCSVFilepath)); + + if (inputCSV.length !== expectedCSV.length) { + return false; + } + + for (let i = 0; i < expectedCSV.length; i++) { + const line = expectedCSV[i]; + for (let j = 0; j < line.length; j++) { + if (line[j] !== inputCSV[i][j]) { + return false; + } + } + } + + return true; + }, + }); + + on('task', { + async comparePDFs({ expectedPDFFilepath, newPDFContent, updateExpectedPDF = false }) { + const inputBuffer = Buffer.from(newPDFContent.data); + if (updateExpectedPDF) { + fs.writeFileSync(expectedPDFFilepath, inputBuffer); + return true; + } + + const inputDoc = await pdf(inputBuffer); + const expectedDoc = await pdf(expectedPDFFilepath); + + removePDFGeneratedOnDate(inputDoc); + removePDFGeneratedOnDate(expectedDoc); + + if (inputDoc.numpages !== expectedDoc.numpages) { + console.log('PDFs do not contain the same number of pages'); + return false; + } + + if (inputDoc.text !== expectedDoc.text) { + console.log('PDFs do not contain the same text'); + console.log('PDF expected text: ', expectedDoc.text); + console.log('PDF input text: ', inputDoc.text); + return false; + } + + return true; + }, + }); +}; + +const toCSV = (buffer) => { + return buffer + .toString() + .split('\n') + .map((e) => e.trim()) + .map((e) => e.split(',').map((e) => e.trim())); +}; + +// remove the date part of the "Generated on " header in PDFs as it's too complicated to set it to a fixed date +const removePDFGeneratedOnDate = (pdfDoc) => { + const regex = /(Generated on )(.*)(Data time range)/; + // removes the text in the second set of parenthesis of the regex above + pdfDoc.text = pdfDoc.text.replace(regex, (match, p1, p2, p3) => { + return `${p1} ${p3}`; + }); }; exports.initialize = initialize; diff --git a/go.mod b/go.mod index 56f6f50aa76..d1c06816d1f 100644 --- a/go.mod +++ b/go.mod @@ -72,7 +72,7 @@ require ( github.com/googleapis/gax-go/v2 v2.14.1 // @grafana/grafana-backend-group github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group github.com/gorilla/websocket v1.5.3 // @grafana/grafana-app-platform-squad - github.com/grafana/alerting v0.0.0-20250205180645-995709fe8d64 // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20250207161551-04c87cf39038 // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250204100101-00a7f40e4029 // @grafana/identity-access-team github.com/grafana/authlib/types v0.0.0-20250120145936-5f0e28e7a87c // @grafana/identity-access-team github.com/grafana/dashboard-linter v0.0.0-20241106223805-1e7999311752 // @grafana/grafana-app-platform-squad diff --git a/go.sum b/go.sum index d2d993d10a6..b5a958e1476 100644 --- a/go.sum +++ b/go.sum @@ -1510,8 +1510,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grafana/alerting v0.0.0-20250205180645-995709fe8d64 h1:J3PIK9OL3ZHPypYHlcK+nBREwYL3ROZ3fJyNMsTYlpk= -github.com/grafana/alerting v0.0.0-20250205180645-995709fe8d64/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU= +github.com/grafana/alerting v0.0.0-20250207161551-04c87cf39038 h1:dG/UKAjY/KlKp9fY8aEm+gSQHHRmPm5q+9cea3hRSu8= +github.com/grafana/alerting v0.0.0-20250207161551-04c87cf39038/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU= github.com/grafana/authlib v0.0.0-20250204100101-00a7f40e4029 h1:0C0a1FEkecxDk60su4uuOozqBzqz/4nmfNFSxXnCViY= github.com/grafana/authlib v0.0.0-20250204100101-00a7f40e4029/go.mod h1:/gYfphsNu9v1qYWXxpv1NSvMEMSwvdf8qb8YlgwIRl8= github.com/grafana/authlib/types v0.0.0-20250120145936-5f0e28e7a87c h1:b0sPDtt33uFdmvUJjSCld3kwE2E49dUvevuUDSJsEuo= diff --git a/package.json b/package.json index 555fb46d6a8..2d7c19a553f 100644 --- a/package.json +++ b/package.json @@ -206,6 +206,7 @@ "jest-junit": "16.0.0", "jest-matcher-utils": "29.7.0", "jest-watch-typeahead": "^2.2.2", + "jimp": "^1.6.0", "jsdom-testing-mocks": "^1.13.1", "knip": "^5.10.0", "lerna": "8.1.8", @@ -216,6 +217,7 @@ "node-notifier": "10.0.1", "nx": "19.8.2", "openapi-types": "^12.1.3", + "pdf-parse": "^1.1.1", "postcss": "8.5.1", "postcss-loader": "8.1.1", "postcss-reporter": "7.1.0", diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx index 5115c6880d1..a27fa4943fc 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx +++ b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx @@ -88,6 +88,7 @@ const getStyles = (theme: GrafanaTheme2, placeholder: string) => { justifyContent: 'start', alignItems: 'center', height: '100%', + overflow: 'hidden', }), placeholder: css({ '::after': { diff --git a/packages/grafana-ui/src/components/Card/Card.tsx b/packages/grafana-ui/src/components/Card/Card.tsx index 0920106d21d..7171b78b08a 100644 --- a/packages/grafana-ui/src/components/Card/Card.tsx +++ b/packages/grafana-ui/src/components/Card/Card.tsx @@ -183,7 +183,8 @@ const getTagStyles = (theme: GrafanaTheme2) => ({ /** Card description text */ const Description = ({ children, className }: ChildProps) => { const styles = useStyles2(getDescriptionStyles); - return
{children}
; + const Element = typeof children === 'string' ? 'p' : 'div'; + return {children}; }; Description.displayName = 'Description'; diff --git a/packages/grafana-ui/src/components/Menu/Menu.tsx b/packages/grafana-ui/src/components/Menu/Menu.tsx index c3b905e5117..8d9e794e535 100644 --- a/packages/grafana-ui/src/components/Menu/Menu.tsx +++ b/packages/grafana-ui/src/components/Menu/Menu.tsx @@ -40,7 +40,7 @@ const MenuComp = React.forwardRef( boxShadow="z3" display="inline-block" onKeyDown={handleKeys} - paddingX={0} + paddingX={0.5} paddingY={0.5} ref={localRef} role="menu" @@ -73,10 +73,11 @@ export const Menu = Object.assign(MenuComp, { const getStyles = (theme: GrafanaTheme2) => { return { header: css({ - padding: theme.spacing(0.5, 1, 1, 1), + padding: theme.spacing(0.5, 0.5, 1, 0.5), }), headerBorder: css({ borderBottom: `1px solid ${theme.colors.border.weak}`, + marginBottom: theme.spacing(0.5), }), }; }; diff --git a/packages/grafana-ui/src/components/Menu/SubMenu.tsx b/packages/grafana-ui/src/components/Menu/SubMenu.tsx index 696a71c9a75..af6bdd23e7c 100644 --- a/packages/grafana-ui/src/components/Menu/SubMenu.tsx +++ b/packages/grafana-ui/src/components/Menu/SubMenu.tsx @@ -78,6 +78,7 @@ const getStyles = (theme: GrafanaTheme2) => { }), itemsWrapper: css({ background: theme.colors.background.primary, + padding: theme.spacing(0.5), boxShadow: theme.shadows.z3, display: 'inline-block', borderRadius: theme.shape.radius.default, diff --git a/packages/grafana-ui/src/components/Tabs/Tab.tsx b/packages/grafana-ui/src/components/Tabs/Tab.tsx index 02980a66860..e866fb4d242 100644 --- a/packages/grafana-ui/src/components/Tabs/Tab.tsx +++ b/packages/grafana-ui/src/components/Tabs/Tab.tsx @@ -53,7 +53,7 @@ export const Tab = React.forwardRef( if (href) { return ( -
+
( } return ( -
+
), }); + leftActions.push({ + group: 'add-panel', + condition: isEditingAndShowingDashboard, + render: () => ( + + ), + }); leftActions.push({ group: 'add-panel', condition: isEditingAndShowingDashboard, diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index 1ee8080fd64..567df0709bf 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -26,6 +26,7 @@ import { getGridItemKeyForPanelId, getDashboardSceneFor, } from '../../utils/utils'; +import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { DashboardGridItem } from './DashboardGridItem'; @@ -177,6 +178,22 @@ export class DefaultGridLayoutManager return panels; } + public hasVizPanels(): boolean { + for (const child of this.state.grid.state.children) { + if (child instanceof DashboardGridItem) { + return true; + } else if (child instanceof SceneGridRow) { + for (const rowChild of child.state.children) { + if (rowChild instanceof DashboardGridItem) { + return true; + } + } + } + } + + return false; + } + public addNewRow(): SceneGridRow { const id = dashboardSceneGraph.getNextPanelId(this); @@ -205,6 +222,17 @@ export class DefaultGridLayoutManager return row; } + public addNewTab() { + const shouldAddTab = this.hasVizPanels(); + const tabsLayout = TabsLayoutManager.createFromLayout(this); + + if (shouldAddTab) { + tabsLayout.addNewTab(); + } + + getDashboardSceneFor(this).switchLayout(tabsLayout); + } + public editModeChanged(isEditing: boolean) { const updateResizeAndDragging = () => { this.state.grid.setState({ isDraggable: isEditing, isResizable: isEditing }); diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx index 04061ea503d..928ac786861 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx @@ -5,6 +5,7 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph'; import { getDashboardSceneFor, getGridItemKeyForPanelId, getVizPanelKeyForPanelId } from '../../utils/utils'; import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager'; +import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { ResponsiveGridItem } from './ResponsiveGridItem'; @@ -101,12 +102,38 @@ export class ResponsiveGridLayoutManager return panels; } + public hasVizPanels(): boolean { + for (const child of this.state.layout.state.children) { + if (child instanceof ResponsiveGridItem) { + return true; + } + } + + return false; + } + public addNewRow() { + const shouldAddRow = this.hasVizPanels(); const rowsLayout = RowsLayoutManager.createFromLayout(this); - rowsLayout.addNewRow(); + + if (shouldAddRow) { + rowsLayout.addNewRow(); + } + getDashboardSceneFor(this).switchLayout(rowsLayout); } + public addNewTab() { + const shouldAddTab = this.hasVizPanels(); + const tabsLayout = TabsLayoutManager.createFromLayout(this); + + if (shouldAddTab) { + tabsLayout.addNewTab(); + } + + getDashboardSceneFor(this).switchLayout(tabsLayout); + } + public getOptions(): OptionsPaneItemDescriptor[] { return getEditOptions(this); } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx index f9c5b5e43ba..0c770a333b1 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -3,9 +3,11 @@ import { t } from 'app/core/internationalization'; import { isClonedKey } from '../../utils/clone'; import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph'; +import { getDashboardSceneFor } from '../../utils/utils'; import { DashboardGridItem } from '../layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager'; import { RowRepeaterBehavior } from '../layout-default/RowRepeaterBehavior'; +import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { RowItem } from './RowItem'; @@ -62,10 +64,31 @@ export class RowsLayoutManager extends SceneObjectBase i return panels; } + public hasVizPanels(): boolean { + for (const row of this.state.rows) { + if (row.getLayout().hasVizPanels()) { + return true; + } + } + + return false; + } + public addNewRow() { this.setState({ rows: [...this.state.rows, new RowItem()] }); } + public addNewTab() { + const shouldAddTab = this.hasVizPanels(); + const tabsLayout = TabsLayoutManager.createFromLayout(this); + + if (shouldAddTab) { + tabsLayout.addNewTab(); + } + + getDashboardSceneFor(this).switchLayout(tabsLayout); + } + public editModeChanged(isEditing: boolean) { this.state.rows.forEach((row) => row.getLayout().editModeChanged?.(isEditing)); } @@ -87,9 +110,8 @@ export class RowsLayoutManager extends SceneObjectBase i } public removeRow(row: RowItem) { - this.setState({ - rows: this.state.rows.filter((r) => r !== row), - }); + const rows = this.state.rows.filter((r) => r !== row); + this.setState({ rows: rows.length === 0 ? [new RowItem()] : rows }); } public static createEmpty(): RowsLayoutManager { diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx new file mode 100644 index 00000000000..73ce7af6b19 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx @@ -0,0 +1,80 @@ +import { ReactNode } from 'react'; + +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 { 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 { getEditOptions, renderActions } from './TabItemEditor'; +import { TabItemRenderer } from './TabItemRenderer'; +import { TabItems } from './TabItems'; +import { TabsLayoutManager } from './TabsLayoutManager'; + +export interface TabItemState extends SceneObjectState { + layout: DashboardLayoutManager; + title?: string; +} + +export class TabItem + extends SceneObjectBase + implements LayoutParent, BulkActionElement, EditableDashboardElement +{ + public static Component = TabItemRenderer; + + protected _variableDependency = new VariableDependencyConfig(this, { + statePaths: ['title'], + }); + + public readonly isEditableDashboardElement = true; + public readonly typeName = 'Tab'; + + constructor(state?: Partial) { + super({ + ...state, + title: state?.title ?? t('dashboard.tabs-layout.tab.new', 'New tab'), + layout: state?.layout ?? ResponsiveGridLayoutManager.createEmpty(), + }); + } + + public getLayout(): DashboardLayoutManager { + return this.state.layout; + } + + public switchLayout(layout: DashboardLayoutManager) { + this.setState({ layout }); + } + + public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { + return getEditOptions(this); + } + + public renderActions(): ReactNode { + return renderActions(this); + } + + public getParentLayout(): TabsLayoutManager { + return sceneGraph.getAncestor(this, TabsLayoutManager); + } + + public onDelete() { + const layout = sceneGraph.getAncestor(this, TabsLayoutManager); + layout.removeTab(this); + } + + public createMultiSelectedElement(items: SceneObject[]): TabItems { + return new TabItems(items.filter((item) => item instanceof TabItem)); + } + + public onChangeTab() { + this.getParentLayout().changeTab(this); + } + + public onChangeTitle(title: string) { + this.setState({ title }); + } +} diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx new file mode 100644 index 00000000000..7d0e8c70b8c --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx @@ -0,0 +1,45 @@ +import { ReactNode, useMemo } from 'react'; + +import { Button, Input } 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 { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector'; + +import { TabItem } from './TabItem'; + +export function getEditOptions(model: TabItem): OptionsPaneCategoryDescriptor[] { + const tabOptions = useMemo(() => { + return new OptionsPaneCategoryDescriptor({ + title: t('dashboard.tabs-layout.tab-options.title', 'Tab options'), + id: 'tab-options', + isOpenDefault: true, + }).addItem( + new OptionsPaneItemDescriptor({ + title: t('dashboard.tabs-layout.tab-options.title-option', 'Title'), + render: () => , + }) + ); + }, [model]); + + const { layout } = model.useState(); + const layoutOptions = useLayoutCategory(layout); + + return [tabOptions, layoutOptions]; +} + +export function renderActions(tab: TabItem): ReactNode { + return ( + <> +