Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
commit
24cfb93ef6
74 files changed
+1975
-602
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}}`)
|
||||
|
||||
@@ -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 <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(),
|
||||
@@ -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 <a href='/connections/datasources/edit/%s'>data source configuration</a>"+
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <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(),
|
||||
@@ -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 <a href='/plugins/%s?page=version-history'>plugin admin page</a>"+
|
||||
|
||||
@@ -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 <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.",
|
||||
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 <a href='/plugins/plugin2?page=version-history'>plugin admin page</a> 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 <a href='/plugins/plugin2?page=version-history'>plugin admin page</a> 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 <date>" 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -88,6 +88,7 @@ const getStyles = (theme: GrafanaTheme2, placeholder: string) => {
|
||||
justifyContent: 'start',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
placeholder: css({
|
||||
'::after': {
|
||||
|
||||
@@ -183,7 +183,8 @@ const getTagStyles = (theme: GrafanaTheme2) => ({
|
||||
/** Card description text */
|
||||
const Description = ({ children, className }: ChildProps) => {
|
||||
const styles = useStyles2(getDescriptionStyles);
|
||||
return <div className={cx(styles.description, className)}>{children}</div>;
|
||||
const Element = typeof children === 'string' ? 'p' : 'div';
|
||||
return <Element className={cx(styles.description, className)}>{children}</Element>;
|
||||
};
|
||||
Description.displayName = 'Description';
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ const MenuComp = React.forwardRef<HTMLDivElement, MenuProps>(
|
||||
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),
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -53,7 +53,7 @@ export const Tab = React.forwardRef<HTMLElement, TabProps>(
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<div className={tabsStyles.item}>
|
||||
<div className={cx(tabsStyles.item, className)}>
|
||||
<a
|
||||
{...commonProps}
|
||||
href={href}
|
||||
@@ -68,7 +68,7 @@ export const Tab = React.forwardRef<HTMLElement, TabProps>(
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={tabsStyles.item}>
|
||||
<div className={cx(tabsStyles.item, className)}>
|
||||
<button
|
||||
{...commonProps}
|
||||
type="button"
|
||||
|
||||
@@ -32,7 +32,7 @@ WHERE dashboard.is_folder = {{ .Arg .Query.GetFolders }}
|
||||
{{ else if .Query.LastID }}
|
||||
AND dashboard_version.version <= {{ .Arg .Query.LastID }}
|
||||
{{ end }}
|
||||
ORDER BY
|
||||
ORDER BY
|
||||
dashboard_version.created DESC,
|
||||
dashboard_version.version DESC,
|
||||
dashboard.uid ASC
|
||||
@@ -40,7 +40,7 @@ WHERE dashboard.is_folder = {{ .Arg .Query.GetFolders }}
|
||||
{{ if .Query.UID }}
|
||||
AND dashboard.uid = {{ .Arg .Query.UID }}
|
||||
{{ else if .Query.LastID }}
|
||||
AND dashboard.id > {{ .Arg .Query.LastID }}
|
||||
AND dashboard.id < {{ .Arg .Query.LastID }}
|
||||
{{ end }}
|
||||
{{ if .Query.GetTrash }}
|
||||
AND dashboard.deleted IS NOT NULL
|
||||
|
||||
Vendored
+1
-1
@@ -15,6 +15,6 @@ LEFT OUTER JOIN `grafana`.`user` as created_user ON dashboard.created_by = creat
|
||||
LEFT OUTER JOIN `grafana`.`user` as updated_user ON dashboard.updated_by = updated_user.id
|
||||
WHERE dashboard.is_folder = FALSE
|
||||
AND dashboard.org_id = 2
|
||||
AND dashboard.id > 22
|
||||
AND dashboard.id < 22
|
||||
AND dashboard.deleted IS NULL
|
||||
ORDER BY dashboard.id DESC
|
||||
Vendored
+1
-1
@@ -18,7 +18,7 @@ WHERE dashboard.is_folder = FALSE
|
||||
AND dashboard.org_id = 2
|
||||
AND dashboard.uid = 'UUU'
|
||||
AND dashboard_version.version = 3
|
||||
ORDER BY
|
||||
ORDER BY
|
||||
dashboard_version.created DESC,
|
||||
dashboard_version.version DESC,
|
||||
dashboard.uid ASC
|
||||
Vendored
+1
-1
@@ -15,6 +15,6 @@ LEFT OUTER JOIN "grafana"."user" as created_user ON dashboard.created_by = creat
|
||||
LEFT OUTER JOIN "grafana"."user" as updated_user ON dashboard.updated_by = updated_user.id
|
||||
WHERE dashboard.is_folder = FALSE
|
||||
AND dashboard.org_id = 2
|
||||
AND dashboard.id > 22
|
||||
AND dashboard.id < 22
|
||||
AND dashboard.deleted IS NULL
|
||||
ORDER BY dashboard.id DESC
|
||||
Vendored
+1
-1
@@ -18,7 +18,7 @@ WHERE dashboard.is_folder = FALSE
|
||||
AND dashboard.org_id = 2
|
||||
AND dashboard.uid = 'UUU'
|
||||
AND dashboard_version.version = 3
|
||||
ORDER BY
|
||||
ORDER BY
|
||||
dashboard_version.created DESC,
|
||||
dashboard_version.version DESC,
|
||||
dashboard.uid ASC
|
||||
Vendored
+1
-1
@@ -15,6 +15,6 @@ LEFT OUTER JOIN "grafana"."user" as created_user ON dashboard.created_by = creat
|
||||
LEFT OUTER JOIN "grafana"."user" as updated_user ON dashboard.updated_by = updated_user.id
|
||||
WHERE dashboard.is_folder = FALSE
|
||||
AND dashboard.org_id = 2
|
||||
AND dashboard.id > 22
|
||||
AND dashboard.id < 22
|
||||
AND dashboard.deleted IS NULL
|
||||
ORDER BY dashboard.id DESC
|
||||
Vendored
+1
-1
@@ -18,7 +18,7 @@ WHERE dashboard.is_folder = FALSE
|
||||
AND dashboard.org_id = 2
|
||||
AND dashboard.uid = 'UUU'
|
||||
AND dashboard_version.version = 3
|
||||
ORDER BY
|
||||
ORDER BY
|
||||
dashboard_version.created DESC,
|
||||
dashboard_version.version DESC,
|
||||
dashboard.uid ASC
|
||||
@@ -221,11 +221,14 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
// get limit and offset from query params
|
||||
limit := 50
|
||||
offset := 0
|
||||
page := 1
|
||||
if queryParams.Has("limit") {
|
||||
limit, _ = strconv.Atoi(queryParams.Get("limit"))
|
||||
}
|
||||
if queryParams.Has("offset") {
|
||||
offset, _ = strconv.Atoi(queryParams.Get("offset"))
|
||||
} else if queryParams.Has("page") {
|
||||
page, _ = strconv.Atoi(queryParams.Get("page"))
|
||||
}
|
||||
|
||||
searchRequest := &resource.ResourceSearchRequest{
|
||||
@@ -233,7 +236,7 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
Query: queryParams.Get("query"),
|
||||
Limit: int64(limit),
|
||||
Offset: int64(offset),
|
||||
Page: int64(offset), // on modes 0-2 (legacy) we use "Page" instead of "Offset"
|
||||
Page: int64(page), // for modes 0-2 (legacy)
|
||||
Explain: queryParams.Has("explain") && queryParams.Get("explain") != "false",
|
||||
}
|
||||
fields := []string{"title", "folder", "tags"}
|
||||
|
||||
@@ -1686,6 +1686,17 @@ func (dr *DashboardServiceImpl) searchDashboardsThroughK8sRaw(ctx context.Contex
|
||||
Values: query.FolderUIDs,
|
||||
}}
|
||||
request.Options.Fields = append(request.Options.Fields, req...)
|
||||
} else if len(query.FolderIds) > 0 { // nolint:staticcheck
|
||||
values := make([]string, len(query.FolderIds)) // nolint:staticcheck
|
||||
for i, id := range query.FolderIds { // nolint:staticcheck
|
||||
values[i] = strconv.FormatInt(id, 10)
|
||||
}
|
||||
|
||||
request.Options.Labels = append(request.Options.Labels, &resource.Requirement{
|
||||
Key: utils.LabelKeyDeprecatedInternalID, // nolint:staticcheck
|
||||
Operator: string(selection.In),
|
||||
Values: values,
|
||||
})
|
||||
}
|
||||
|
||||
if query.ProvisionedRepo != "" {
|
||||
@@ -1744,7 +1755,7 @@ func (dr *DashboardServiceImpl) searchDashboardsThroughK8sRaw(ctx context.Contex
|
||||
|
||||
request.Limit = query.Limit
|
||||
request.Page = query.Page
|
||||
request.Offset = query.Page - 1 // bleve's offset is 0 indexed
|
||||
request.Offset = (query.Page - 1) * query.Limit // only relevant when running in modes 3+
|
||||
|
||||
namespace := dr.k8sclient.GetNamespace(query.OrgId)
|
||||
var err error
|
||||
|
||||
@@ -242,6 +242,10 @@ func (srv RulerSrv) RouteGetRulesGroupConfig(c *contextmodel.ReqContext, namespa
|
||||
return errorToResponse(err)
|
||||
}
|
||||
|
||||
if len(rules) == 0 {
|
||||
return ErrResp(http.StatusNotFound, errors.New("rule group does not exist"), "")
|
||||
}
|
||||
|
||||
provenanceRecords, err := srv.provenanceStore.GetProvenances(c.Req.Context(), c.SignedInUser.GetOrgID(), (&ngmodels.AlertRule{}).ResourceType())
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusInternalServerError, err, "failed to get group alert rules")
|
||||
@@ -251,6 +255,7 @@ func (srv RulerSrv) RouteGetRulesGroupConfig(c *contextmodel.ReqContext, namespa
|
||||
// nolint:staticcheck
|
||||
GettableRuleGroupConfig: toGettableRuleGroupConfig(finalRuleGroup, rules, provenanceRecords, srv.resolveUserIdToNameFn(c.Req.Context())),
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusAccepted, result)
|
||||
}
|
||||
|
||||
|
||||
@@ -694,6 +694,24 @@ func TestRouteGetRulesGroupConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("should return a 404 when fetching a group that doesn't exist", func(t *testing.T) {
|
||||
orgID := rand.Int63()
|
||||
folder := randFolder()
|
||||
ruleStore := fakes.NewRuleStore(t)
|
||||
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder)
|
||||
groupKey := models.GenerateGroupKey(orgID)
|
||||
groupKey.NamespaceUID = folder.UID
|
||||
|
||||
expectedRules := gen.With(gen.WithGroupKey(groupKey), gen.WithUniqueGroupIndex()).GenerateManyRef(5, 10)
|
||||
ruleStore.PutRule(context.Background(), expectedRules...)
|
||||
|
||||
perms := createPermissionsForRules(expectedRules, orgID)
|
||||
req := createRequestContextWithPerms(orgID, perms, nil)
|
||||
|
||||
response := createService(ruleStore).RouteGetRulesGroupConfig(req, folder.UID, "non-existent-rule-group")
|
||||
|
||||
require.Equal(t, http.StatusNotFound, response.Status())
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerifyProvisionedRulesNotAffected(t *testing.T) {
|
||||
|
||||
@@ -167,6 +167,7 @@ import (
|
||||
// Responses:
|
||||
// 202: RuleGroupConfigResponse
|
||||
// 403: ForbiddenError
|
||||
// 404: NotFound
|
||||
|
||||
// swagger:route Get /ruler/{DatasourceUID}/api/v1/rules/{Namespace}/{Groupname} ruler RouteGetRulegGroupConfig
|
||||
//
|
||||
|
||||
@@ -175,7 +175,7 @@ require (
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250205180645-995709fe8d64 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250207161551-04c87cf39038 // indirect
|
||||
github.com/grafana/authlib v0.0.0-20250204100101-00a7f40e4029 // indirect
|
||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||
github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040 // indirect
|
||||
|
||||
@@ -557,8 +557,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
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=
|
||||
|
||||
@@ -117,7 +117,7 @@ require (
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250205180645-995709fe8d64 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250207161551-04c87cf39038 // indirect
|
||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||
github.com/grafana/grafana-app-sdk/logging v0.30.0 // indirect
|
||||
github.com/grafana/grafana-aws-sdk v0.31.5 // indirect
|
||||
|
||||
@@ -421,8 +421,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z
|
||||
github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
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=
|
||||
|
||||
@@ -431,15 +431,19 @@ func (s *searchSupport) handleEvent(ctx context.Context, evt *WrittenEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
_, buildDocSpan := s.tracer.Start(ctx, tracingPrexfixSearch+"BuildDocument")
|
||||
doc, err := builder.BuildDocument(ctx, evt.Key, evt.ResourceVersion, evt.Value)
|
||||
if err != nil {
|
||||
s.log.Warn("error building document watch event", "error", err)
|
||||
return
|
||||
}
|
||||
buildDocSpan.End()
|
||||
|
||||
switch evt.Type {
|
||||
case WatchEvent_ADDED, WatchEvent_MODIFIED:
|
||||
_, writeSpan := s.tracer.Start(ctx, tracingPrexfixSearch+"WriteDocument")
|
||||
err = index.Write(doc)
|
||||
writeSpan.End()
|
||||
if err != nil {
|
||||
s.log.Warn("error writing document watch event", "error", err)
|
||||
return
|
||||
@@ -448,7 +452,9 @@ func (s *searchSupport) handleEvent(ctx context.Context, evt *WrittenEvent) {
|
||||
IndexMetrics.IndexedKinds.WithLabelValues(evt.Key.Resource).Inc()
|
||||
}
|
||||
case WatchEvent_DELETED:
|
||||
_, deleteSpan := s.tracer.Start(ctx, tracingPrexfixSearch+"DeleteDocument")
|
||||
err = index.Delete(evt.Key)
|
||||
deleteSpan.End()
|
||||
if err != nil {
|
||||
s.log.Warn("error deleting document watch event", "error", err)
|
||||
return
|
||||
|
||||
@@ -157,7 +157,8 @@ func TestIntegrationAlertRulePermissions(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Get group returns a single group", func(t *testing.T) {
|
||||
rules := apiClient.GetRulesGroup(t, "folder2", allRules["folder2"][0].Name)
|
||||
rules, status := apiClient.GetRulesGroup(t, "folder2", allRules["folder2"][0].Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
cmp.Diff(allRules["folder2"][0], rules.GettableRuleGroupConfig)
|
||||
})
|
||||
|
||||
@@ -482,7 +483,8 @@ func TestIntegrationAlertRuleNestedPermissions(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Get group returns a single group", func(t *testing.T) {
|
||||
rules := apiClient.GetRulesGroup(t, "folder2", allRules["folder2"][0].Name)
|
||||
rules, status := apiClient.GetRulesGroup(t, "folder2", allRules["folder2"][0].Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
cmp.Diff(allRules["folder2"][0], rules.GettableRuleGroupConfig)
|
||||
})
|
||||
|
||||
@@ -839,10 +841,11 @@ func TestIntegrationAlertRuleEditorSettings(t *testing.T) {
|
||||
}
|
||||
|
||||
respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, folderName, &rules)
|
||||
assert.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Len(t, respModel.Created, 1)
|
||||
|
||||
createdRuleGroup := apiClient.GetRulesGroup(t, folderName, rules.Name).GettableRuleGroupConfig
|
||||
createdRuleGroup, status := apiClient.GetRulesGroup(t, folderName, rules.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Len(t, createdRuleGroup.Rules, 1)
|
||||
|
||||
expectedMetadata := alertRule.GrafanaManagedAlert.Metadata
|
||||
@@ -855,7 +858,7 @@ func TestIntegrationAlertRuleEditorSettings(t *testing.T) {
|
||||
}
|
||||
require.Equal(t, expectedMetadata, createdRuleGroup.Rules[0].GrafanaManagedAlert.Metadata)
|
||||
|
||||
return createdRuleGroup
|
||||
return createdRuleGroup.GettableRuleGroupConfig
|
||||
}
|
||||
|
||||
t.Run("set simplified query editor in editor settings", func(t *testing.T) {
|
||||
@@ -870,9 +873,10 @@ func TestIntegrationAlertRuleEditorSettings(t *testing.T) {
|
||||
rulesWithUID.Rules[0].GrafanaManagedAlert.Metadata.EditorSettings.SimplifiedQueryAndExpressionsSection = true
|
||||
|
||||
_, status, _ := apiClient.PostRulesGroupWithStatus(t, folderName, &rulesWithUID)
|
||||
assert.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
|
||||
updatedRuleGroup := apiClient.GetRulesGroup(t, folderName, groupName).GettableRuleGroupConfig
|
||||
updatedRuleGroup, status := apiClient.GetRulesGroup(t, folderName, groupName)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Len(t, updatedRuleGroup.Rules, 1)
|
||||
require.True(t, updatedRuleGroup.Rules[0].GrafanaManagedAlert.Metadata.EditorSettings.SimplifiedQueryAndExpressionsSection)
|
||||
})
|
||||
@@ -891,9 +895,10 @@ func TestIntegrationAlertRuleEditorSettings(t *testing.T) {
|
||||
rulesWithUID.Rules[0].GrafanaManagedAlert.Metadata.EditorSettings.SimplifiedQueryAndExpressionsSection = false
|
||||
|
||||
_, status, _ := apiClient.PostRulesGroupWithStatus(t, folderName, &rulesWithUID)
|
||||
assert.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
|
||||
updatedRuleGroup := apiClient.GetRulesGroup(t, folderName, groupName).GettableRuleGroupConfig
|
||||
updatedRuleGroup, status := apiClient.GetRulesGroup(t, folderName, groupName)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Len(t, updatedRuleGroup.Rules, 1)
|
||||
require.False(t, updatedRuleGroup.Rules[0].GrafanaManagedAlert.Metadata.EditorSettings.SimplifiedQueryAndExpressionsSection)
|
||||
})
|
||||
@@ -910,9 +915,10 @@ func TestIntegrationAlertRuleEditorSettings(t *testing.T) {
|
||||
rulesWithUID.Rules[0].GrafanaManagedAlert.Metadata.EditorSettings.SimplifiedNotificationsSection = true
|
||||
|
||||
_, status, _ := apiClient.PostRulesGroupWithStatus(t, folderName, &rulesWithUID)
|
||||
assert.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
|
||||
updatedRuleGroup := apiClient.GetRulesGroup(t, folderName, groupName).GettableRuleGroupConfig
|
||||
updatedRuleGroup, status := apiClient.GetRulesGroup(t, folderName, groupName)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Len(t, updatedRuleGroup.Rules, 1)
|
||||
require.True(t, updatedRuleGroup.Rules[0].GrafanaManagedAlert.Metadata.EditorSettings.SimplifiedNotificationsSection)
|
||||
})
|
||||
@@ -931,9 +937,10 @@ func TestIntegrationAlertRuleEditorSettings(t *testing.T) {
|
||||
rulesWithUID.Rules[0].GrafanaManagedAlert.Metadata.EditorSettings.SimplifiedNotificationsSection = false
|
||||
|
||||
_, status, _ := apiClient.PostRulesGroupWithStatus(t, folderName, &rulesWithUID)
|
||||
assert.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
|
||||
updatedRuleGroup := apiClient.GetRulesGroup(t, folderName, groupName).GettableRuleGroupConfig
|
||||
updatedRuleGroup, status := apiClient.GetRulesGroup(t, folderName, groupName)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Len(t, updatedRuleGroup.Rules, 1)
|
||||
require.False(t, updatedRuleGroup.Rules[0].GrafanaManagedAlert.Metadata.EditorSettings.SimplifiedNotificationsSection)
|
||||
})
|
||||
@@ -941,7 +948,8 @@ func TestIntegrationAlertRuleEditorSettings(t *testing.T) {
|
||||
t.Run("post alert without metadata", func(t *testing.T) {
|
||||
createAlertInGrafana(nil)
|
||||
|
||||
createdRuleGroup := apiClient.GetRulesGroup(t, folderName, groupName).GettableRuleGroupConfig
|
||||
createdRuleGroup, status := apiClient.GetRulesGroup(t, folderName, groupName)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Len(t, createdRuleGroup.Rules, 1)
|
||||
require.False(t, createdRuleGroup.Rules[0].GrafanaManagedAlert.Metadata.EditorSettings.SimplifiedQueryAndExpressionsSection)
|
||||
})
|
||||
@@ -979,16 +987,17 @@ func TestIntegrationAlertRuleConflictingTitle(t *testing.T) {
|
||||
rules := newTestingRuleConfig(t)
|
||||
|
||||
respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, "folder1", &rules)
|
||||
assert.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Len(t, respModel.Created, len(rules.Rules))
|
||||
|
||||
// fetch the created rules, so we can get the uid's and trigger
|
||||
// and update by reusing the uid's
|
||||
createdRuleGroup := apiClient.GetRulesGroup(t, "folder1", rules.Name).GettableRuleGroupConfig
|
||||
createdRuleGroup, status := apiClient.GetRulesGroup(t, "folder1", rules.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Len(t, createdRuleGroup.Rules, 2)
|
||||
|
||||
t.Run("trying to create alert with same title under same folder should fail", func(t *testing.T) {
|
||||
rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup)
|
||||
rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup.GettableRuleGroupConfig)
|
||||
rulesWithUID.Rules = append(rulesWithUID.Rules, rules.Rules[0]) // Create new copy of first rule.
|
||||
|
||||
_, status, body := apiClient.PostRulesGroupWithStatus(t, "folder1", &rulesWithUID)
|
||||
@@ -1000,7 +1009,7 @@ func TestIntegrationAlertRuleConflictingTitle(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("trying to update an alert to the title of an existing alert in the same folder should fail", func(t *testing.T) {
|
||||
rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup)
|
||||
rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup.GettableRuleGroupConfig)
|
||||
rulesWithUID.Rules[1].GrafanaManagedAlert.Title = "AlwaysFiring"
|
||||
|
||||
_, status, body := apiClient.PostRulesGroupWithStatus(t, "folder1", &rulesWithUID)
|
||||
@@ -1014,29 +1023,29 @@ func TestIntegrationAlertRuleConflictingTitle(t *testing.T) {
|
||||
t.Run("trying to create alert with same title under another folder should succeed", func(t *testing.T) {
|
||||
rules := newTestingRuleConfig(t)
|
||||
resp, status, _ := apiClient.PostRulesGroupWithStatus(t, "folder2", &rules)
|
||||
assert.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Len(t, resp.Created, len(rules.Rules))
|
||||
})
|
||||
|
||||
t.Run("trying to swap titles of existing alerts in the same folder should work", func(t *testing.T) {
|
||||
rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup)
|
||||
rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup.GettableRuleGroupConfig)
|
||||
title0 := rulesWithUID.Rules[0].GrafanaManagedAlert.Title
|
||||
title1 := rulesWithUID.Rules[1].GrafanaManagedAlert.Title
|
||||
rulesWithUID.Rules[0].GrafanaManagedAlert.Title = title1
|
||||
rulesWithUID.Rules[1].GrafanaManagedAlert.Title = title0
|
||||
|
||||
resp, status, _ := apiClient.PostRulesGroupWithStatus(t, "folder1", &rulesWithUID)
|
||||
assert.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Len(t, resp.Updated, 2)
|
||||
})
|
||||
|
||||
t.Run("trying to update titles of existing alerts in a chain in the same folder should work", func(t *testing.T) {
|
||||
rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup)
|
||||
rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup.GettableRuleGroupConfig)
|
||||
rulesWithUID.Rules[0].GrafanaManagedAlert.Title = rulesWithUID.Rules[1].GrafanaManagedAlert.Title
|
||||
rulesWithUID.Rules[1].GrafanaManagedAlert.Title = "something new"
|
||||
|
||||
resp, status, _ := apiClient.PostRulesGroupWithStatus(t, "folder1", &rulesWithUID)
|
||||
assert.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Len(t, resp.Updated, len(rulesWithUID.Rules))
|
||||
})
|
||||
}
|
||||
@@ -1126,7 +1135,7 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) {
|
||||
},
|
||||
}
|
||||
resp, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules)
|
||||
assert.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Len(t, resp.Created, len(rules.Rules))
|
||||
}
|
||||
|
||||
@@ -1438,7 +1447,8 @@ func TestIntegrationRuleGroupSequence(t *testing.T) {
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
|
||||
t.Run("should persist order of the rules in a group", func(t *testing.T) {
|
||||
group1Get := client.GetRulesGroup(t, folderUID, group1.Name)
|
||||
group1Get, status := client.GetRulesGroup(t, folderUID, group1.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
assert.Equal(t, group1.Name, group1Get.Name)
|
||||
assert.Equal(t, group1.Interval, group1Get.Interval)
|
||||
assert.Len(t, group1Get.Rules, len(group1.Rules))
|
||||
@@ -1457,10 +1467,11 @@ func TestIntegrationRuleGroupSequence(t *testing.T) {
|
||||
for _, rule := range postableGroup1.Rules {
|
||||
expectedUids = append(expectedUids, rule.GrafanaManagedAlert.UID)
|
||||
}
|
||||
_, status, _ := client.PostRulesGroupWithStatus(t, folderUID, &postableGroup1)
|
||||
_, status, _ = client.PostRulesGroupWithStatus(t, folderUID, &postableGroup1)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
|
||||
group1Get = client.GetRulesGroup(t, folderUID, group1.Name)
|
||||
group1Get, status = client.GetRulesGroup(t, folderUID, group1.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
|
||||
require.Len(t, group1Get.Rules, len(postableGroup1.Rules))
|
||||
|
||||
@@ -1472,8 +1483,10 @@ func TestIntegrationRuleGroupSequence(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("should be able to move a rule from another group in a specific position", func(t *testing.T) {
|
||||
group1Get := client.GetRulesGroup(t, folderUID, group1.Name)
|
||||
group2Get := client.GetRulesGroup(t, folderUID, group2.Name)
|
||||
group1Get, status := client.GetRulesGroup(t, folderUID, group1.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
group2Get, status := client.GetRulesGroup(t, folderUID, group2.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
|
||||
movedRule := convertGettableRuleToPostable(group2Get.Rules[3])
|
||||
// now shuffle the rules
|
||||
@@ -1483,10 +1496,11 @@ func TestIntegrationRuleGroupSequence(t *testing.T) {
|
||||
for _, rule := range postableGroup1.Rules {
|
||||
expectedUids = append(expectedUids, rule.GrafanaManagedAlert.UID)
|
||||
}
|
||||
_, status, _ := client.PostRulesGroupWithStatus(t, folderUID, &postableGroup1)
|
||||
_, status, _ = client.PostRulesGroupWithStatus(t, folderUID, &postableGroup1)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
|
||||
group1Get = client.GetRulesGroup(t, folderUID, group1.Name)
|
||||
group1Get, status = client.GetRulesGroup(t, folderUID, group1.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
|
||||
require.Len(t, group1Get.Rules, len(postableGroup1.Rules))
|
||||
|
||||
@@ -1496,7 +1510,8 @@ func TestIntegrationRuleGroupSequence(t *testing.T) {
|
||||
}
|
||||
assert.Equal(t, expectedUids, actualUids)
|
||||
|
||||
group2Get = client.GetRulesGroup(t, folderUID, group2.Name)
|
||||
group2Get, status = client.GetRulesGroup(t, folderUID, group2.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
assert.Len(t, group2Get.Rules, len(group2.Rules)-1)
|
||||
for _, rule := range group2Get.Rules {
|
||||
require.NotEqual(t, movedRule.GrafanaManagedAlert.UID, rule.GrafanaManagedAlert.UID)
|
||||
@@ -1696,7 +1711,8 @@ func TestIntegrationRuleUpdate(t *testing.T) {
|
||||
|
||||
_, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
getGroup, status := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, expected, *getGroup.Rules[0].ApiRuleNode.For)
|
||||
|
||||
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
@@ -1705,7 +1721,8 @@ func TestIntegrationRuleUpdate(t *testing.T) {
|
||||
_, status, body = client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
|
||||
getGroup = client.GetRulesGroup(t, folderUID, group.Name)
|
||||
getGroup, status = client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, expected, *getGroup.Rules[0].ApiRuleNode.For)
|
||||
})
|
||||
t.Run("when data source missing", func(t *testing.T) {
|
||||
@@ -1717,7 +1734,8 @@ func TestIntegrationRuleUpdate(t *testing.T) {
|
||||
_, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
|
||||
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
getGroup, status := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
|
||||
require.Len(t, group.Rules, 3)
|
||||
@@ -1731,14 +1749,16 @@ func TestIntegrationRuleUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("noop should not fail", func(t *testing.T) {
|
||||
getGroup := client.GetRulesGroup(t, folderUID, groupName)
|
||||
getGroup, status := client.GetRulesGroup(t, folderUID, groupName)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
|
||||
_, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post noop rule group. Response: %s", body)
|
||||
})
|
||||
t.Run("should not let update rule if it does not fix datasource", func(t *testing.T) {
|
||||
getGroup := client.GetRulesGroup(t, folderUID, groupName)
|
||||
getGroup, status := client.GetRulesGroup(t, folderUID, groupName)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
|
||||
group.Rules[0].GrafanaManagedAlert.Title = uuid.NewString()
|
||||
@@ -1746,14 +1766,16 @@ func TestIntegrationRuleUpdate(t *testing.T) {
|
||||
|
||||
if status == http.StatusAccepted {
|
||||
assert.Len(t, resp.Deleted, 1)
|
||||
getGroup = client.GetRulesGroup(t, folderUID, group.Name)
|
||||
getGroup, status = client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
assert.NotEqualf(t, group.Rules[0].GrafanaManagedAlert.Title, getGroup.Rules[0].GrafanaManagedAlert.Title, "group was updated")
|
||||
}
|
||||
require.Equalf(t, http.StatusBadRequest, status, "expected BadRequest. Response: %s", body)
|
||||
assert.Contains(t, body, "data source not found")
|
||||
})
|
||||
t.Run("should let delete broken rule", func(t *testing.T) {
|
||||
getGroup := client.GetRulesGroup(t, folderUID, groupName)
|
||||
getGroup, status := client.GetRulesGroup(t, folderUID, groupName)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
|
||||
// remove the last rule.
|
||||
@@ -1762,12 +1784,14 @@ func TestIntegrationRuleUpdate(t *testing.T) {
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to delete last rule from group. Response: %s", body)
|
||||
assert.Len(t, resp.Deleted, 1)
|
||||
|
||||
getGroup = client.GetRulesGroup(t, folderUID, group.Name)
|
||||
getGroup, status = client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
require.Len(t, group.Rules, 2)
|
||||
})
|
||||
t.Run("should let fix single rule", func(t *testing.T) {
|
||||
getGroup := client.GetRulesGroup(t, folderUID, groupName)
|
||||
getGroup, status := client.GetRulesGroup(t, folderUID, groupName)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
|
||||
ds2 := adminClient.CreateTestDatasource(t)
|
||||
@@ -1778,7 +1802,8 @@ func TestIntegrationRuleUpdate(t *testing.T) {
|
||||
assert.Len(t, resp.Updated, 2)
|
||||
assert.Len(t, resp.Created, 0)
|
||||
|
||||
getGroup = client.GetRulesGroup(t, folderUID, group.Name)
|
||||
getGroup, status = client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
require.Equal(t, ds2.Body.Datasource.UID, group.Rules[0].GrafanaManagedAlert.Data[0].DatasourceUID)
|
||||
})
|
||||
@@ -1794,7 +1819,8 @@ func TestIntegrationRuleUpdate(t *testing.T) {
|
||||
|
||||
_, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
getGroup, status := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.NotNil(t, getGroup.Rules[0].GrafanaManagedAlert.UpdatedBy)
|
||||
assert.NotEmpty(t, getGroup.Rules[0].GrafanaManagedAlert.UpdatedBy.UID)
|
||||
assert.Equal(t, "grafana", getGroup.Rules[0].GrafanaManagedAlert.UpdatedBy.Name)
|
||||
@@ -1928,7 +1954,7 @@ func TestIntegrationAlertAndGroupsQuery(t *testing.T) {
|
||||
}
|
||||
|
||||
_, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules)
|
||||
assert.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
}
|
||||
|
||||
// Eventually, we'll get an alert with its state being active.
|
||||
@@ -2486,7 +2512,7 @@ func TestIntegrationQuota(t *testing.T) {
|
||||
}
|
||||
|
||||
respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules)
|
||||
assert.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Len(t, respModel.Updated, 1)
|
||||
|
||||
// let's make sure that rule definitions are updated correctly.
|
||||
@@ -2654,7 +2680,7 @@ func TestIntegrationDeleteFolderWithRules(t *testing.T) {
|
||||
"updated": "2021-02-21T01:10:30Z",
|
||||
"updated_by" : {
|
||||
"uid": "uid",
|
||||
"name": "editor"
|
||||
"name": "editor"
|
||||
},
|
||||
"intervalSeconds": 60,
|
||||
"is_paused": false,
|
||||
@@ -3065,7 +3091,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
},
|
||||
}
|
||||
resp, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules)
|
||||
assert.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, "rule group updated successfully", resp.Message)
|
||||
assert.Len(t, resp.Created, 2)
|
||||
assert.Empty(t, resp.Updated)
|
||||
@@ -3134,7 +3160,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
],
|
||||
"updated":"2021-02-21T01:10:30Z",
|
||||
"updated_by": {
|
||||
"uid": "uid",
|
||||
"uid": "uid",
|
||||
"name": "grafana"
|
||||
},
|
||||
"intervalSeconds":60,
|
||||
@@ -3178,7 +3204,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
],
|
||||
"updated":"2021-02-21T01:10:30Z",
|
||||
"updated_by": {
|
||||
"uid": "uid",
|
||||
"uid": "uid",
|
||||
"name": "grafana"
|
||||
},
|
||||
"intervalSeconds":60,
|
||||
@@ -3431,7 +3457,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
Interval: interval,
|
||||
}
|
||||
respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules)
|
||||
assert.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, respModel.Updated, []string{ruleUID})
|
||||
require.Len(t, respModel.Deleted, 1)
|
||||
|
||||
@@ -3494,7 +3520,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
],
|
||||
"updated":"2021-02-21T01:10:30Z",
|
||||
"updated_by": {
|
||||
"uid": "uid",
|
||||
"uid": "uid",
|
||||
"name": "grafana"
|
||||
},
|
||||
"intervalSeconds":60,
|
||||
@@ -3557,7 +3583,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
Interval: interval,
|
||||
}
|
||||
respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules)
|
||||
assert.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, respModel.Updated, []string{ruleUID})
|
||||
|
||||
// let's make sure that rule definitions are updated correctly.
|
||||
@@ -3611,7 +3637,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
],
|
||||
"updated":"2021-02-21T01:10:30Z",
|
||||
"updated_by": {
|
||||
"uid": "uid",
|
||||
"uid": "uid",
|
||||
"name": "grafana"
|
||||
},
|
||||
"intervalSeconds":60,
|
||||
@@ -3650,7 +3676,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
Interval: interval,
|
||||
}
|
||||
respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules)
|
||||
assert.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, "no changes detected in the rule group", respModel.Message)
|
||||
assert.Empty(t, respModel.Created)
|
||||
assert.Empty(t, respModel.Updated)
|
||||
@@ -3707,7 +3733,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
|
||||
],
|
||||
"updated":"2021-02-21T01:10:30Z",
|
||||
"updated_by": {
|
||||
"uid": "uid",
|
||||
"uid": "uid",
|
||||
"name": "grafana"
|
||||
},
|
||||
"intervalSeconds":60,
|
||||
@@ -3804,7 +3830,8 @@ func TestIntegrationRulePause(t *testing.T) {
|
||||
resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
require.Len(t, resp.Created, 1)
|
||||
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
getGroup, status := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body)
|
||||
require.Equal(t, expectedIsPaused, getGroup.Rules[0].GrafanaManagedAlert.IsPaused)
|
||||
})
|
||||
@@ -3817,7 +3844,8 @@ func TestIntegrationRulePause(t *testing.T) {
|
||||
resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
require.Len(t, resp.Created, 1)
|
||||
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
getGroup, status := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body)
|
||||
require.Equal(t, expectedIsPaused, getGroup.Rules[0].GrafanaManagedAlert.IsPaused)
|
||||
})
|
||||
@@ -3829,7 +3857,7 @@ func TestIntegrationRulePause(t *testing.T) {
|
||||
resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
require.Len(t, resp.Created, 1)
|
||||
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
getGroup, status := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body)
|
||||
require.False(t, getGroup.Rules[0].GrafanaManagedAlert.IsPaused)
|
||||
})
|
||||
@@ -3886,7 +3914,7 @@ func TestIntegrationRulePause(t *testing.T) {
|
||||
|
||||
_, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
getGroup, status := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body)
|
||||
|
||||
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
@@ -3894,7 +3922,8 @@ func TestIntegrationRulePause(t *testing.T) {
|
||||
_, status, body = client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
|
||||
getGroup = client.GetRulesGroup(t, folderUID, group.Name)
|
||||
getGroup, status = client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, tc.expectedIsPausedInDb, getGroup.Rules[0].GrafanaManagedAlert.IsPaused)
|
||||
})
|
||||
}
|
||||
@@ -4284,7 +4313,8 @@ func TestIntegrationRuleUpdateAllDatabases(t *testing.T) {
|
||||
|
||||
_, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
getGroup, status := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Lenf(t, getGroup.Rules, 3, "expected 3 rules in group")
|
||||
require.Equal(t, groupName, getGroup.Rules[0].GrafanaManagedAlert.RuleGroup)
|
||||
|
||||
@@ -4294,7 +4324,8 @@ func TestIntegrationRuleUpdateAllDatabases(t *testing.T) {
|
||||
_, status, body = client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
|
||||
getGroup = client.GetRulesGroup(t, folderUID, group.Name)
|
||||
getGroup, status = client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Lenf(t, getGroup.Rules, 3, "expected 3 rules in group")
|
||||
require.Equal(t, newGroup, getGroup.Rules[0].GrafanaManagedAlert.RuleGroup)
|
||||
|
||||
@@ -4302,11 +4333,12 @@ func TestIntegrationRuleUpdateAllDatabases(t *testing.T) {
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post noop rule group. Response: %s", body)
|
||||
|
||||
// Old group is gone.
|
||||
getGroup = client.GetRulesGroup(t, folderUID, groupName)
|
||||
require.Lenf(t, getGroup.Rules, 0, "expected no rules")
|
||||
getGroup, status = client.GetRulesGroup(t, folderUID, groupName)
|
||||
require.Equal(t, http.StatusNotFound, status)
|
||||
|
||||
// New group still exists.
|
||||
getGroup = client.GetRulesGroup(t, folderUID, newGroup)
|
||||
getGroup, status = client.GetRulesGroup(t, folderUID, newGroup)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Lenf(t, getGroup.Rules, 3, "expected 3 rules in group")
|
||||
require.Equal(t, newGroup, getGroup.Rules[0].GrafanaManagedAlert.RuleGroup)
|
||||
})
|
||||
@@ -4357,7 +4389,8 @@ func TestIntegrationRuleVersions(t *testing.T) {
|
||||
assert.Equal(t, ruleV1, versions[0])
|
||||
})
|
||||
|
||||
group1Gettable := apiClient.GetRulesGroup(t, "folder1", group1.Name)
|
||||
group1Gettable, status := apiClient.GetRulesGroup(t, "folder1", group1.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
group1 = convertGettableRuleGroupToPostable(group1Gettable.GettableRuleGroupConfig)
|
||||
group1.Rules[0].Annotations[util.GenerateShortUID()] = util.GenerateShortUID()
|
||||
|
||||
@@ -4538,7 +4571,7 @@ func createRule(t *testing.T, client apiClient, folder string) (apimodels.Postab
|
||||
},
|
||||
}
|
||||
resp, status, _ := client.PostRulesGroupWithStatus(t, folder, &rules)
|
||||
assert.Equal(t, http.StatusAccepted, status)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Len(t, resp.Created, 1)
|
||||
return rules, resp.Created[0]
|
||||
}
|
||||
|
||||
@@ -582,10 +582,9 @@ func (a apiClient) DeleteSilence(t *testing.T, id string) (any, int, string) {
|
||||
return sendRequest[dynamic](t, req, http.StatusOK)
|
||||
}
|
||||
|
||||
func (a apiClient) GetRulesGroup(t *testing.T, folder string, group string) apimodels.RuleGroupConfigResponse {
|
||||
func (a apiClient) GetRulesGroup(t *testing.T, folder string, group string) (apimodels.RuleGroupConfigResponse, int) {
|
||||
result, status, _ := a.GetRulesGroupWithStatus(t, folder, group)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
return result
|
||||
return result, status
|
||||
}
|
||||
|
||||
func (a apiClient) GetRulesGroupWithStatus(t *testing.T, folder string, group string) (apimodels.RuleGroupConfigResponse, int, []byte) {
|
||||
|
||||
@@ -1199,7 +1199,8 @@ func TestIntegrationReferentialIntegrity(t *testing.T) {
|
||||
receiver := receivers.Items[idx]
|
||||
|
||||
currentRoute := legacyCli.GetRoute(t)
|
||||
currentRuleGroup := legacyCli.GetRulesGroup(t, folderUID, ruleGroup.Name)
|
||||
currentRuleGroup, status := legacyCli.GetRulesGroup(t, folderUID, ruleGroup.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
t.Run("should rename all references if name changes", func(t *testing.T) {
|
||||
@@ -1210,7 +1211,8 @@ func TestIntegrationReferentialIntegrity(t *testing.T) {
|
||||
actual, err := adminClient.Update(ctx, renamed, v1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
updatedRuleGroup := legacyCli.GetRulesGroup(t, folderUID, ruleGroup.Name)
|
||||
updatedRuleGroup, status := legacyCli.GetRulesGroup(t, folderUID, ruleGroup.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
for idx, rule := range updatedRuleGroup.Rules {
|
||||
assert.Equalf(t, expectedTitle, rule.GrafanaManagedAlert.NotificationSettings.Receiver, "receiver in rule %d should have been renamed but it did not", idx)
|
||||
}
|
||||
|
||||
@@ -664,7 +664,8 @@ func TestIntegrationTimeIntervalReferentialIntegrity(t *testing.T) {
|
||||
require.Equalf(t, http.StatusAccepted, status, "Failed to post Rule: %s", data)
|
||||
|
||||
currentRoute := legacyCli.GetRoute(t)
|
||||
currentRuleGroup := legacyCli.GetRulesGroup(t, folderUID, ruleGroup.Name)
|
||||
currentRuleGroup, status := legacyCli.GetRulesGroup(t, folderUID, ruleGroup.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
|
||||
adminClient := newClient(t, helper.Org1.Admin)
|
||||
|
||||
@@ -696,7 +697,8 @@ func TestIntegrationTimeIntervalReferentialIntegrity(t *testing.T) {
|
||||
actual, err := adminClient.Update(ctx, renamed, v1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
updatedRuleGroup := legacyCli.GetRulesGroup(t, folderUID, ruleGroup.Name)
|
||||
updatedRuleGroup, status := legacyCli.GetRulesGroup(t, folderUID, ruleGroup.Name)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
for idx, rule := range updatedRuleGroup.Rules {
|
||||
expectedTimeIntervals := currentRuleGroup.Rules[idx].GrafanaManagedAlert.NotificationSettings.MuteTimeIntervals
|
||||
expectedTimeIntervals = replace(expectedTimeIntervals, interval.Spec.Name, actual.Spec.Name)
|
||||
|
||||
@@ -90,7 +90,7 @@ type GrafanaClientAnnotations = {
|
||||
|
||||
// Labels
|
||||
type GrafanaLabels = {
|
||||
[DeprecatedInternalId]?: number;
|
||||
[DeprecatedInternalId]?: string;
|
||||
};
|
||||
|
||||
export interface Resource<T = object, S = object, K = string> extends TypeMeta<K> {
|
||||
|
||||
@@ -596,6 +596,10 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
this.state.body.addNewRow();
|
||||
}
|
||||
|
||||
public onCreateNewTab() {
|
||||
this.state.body.addNewTab();
|
||||
}
|
||||
|
||||
public onCreateNewPanel(): VizPanel {
|
||||
const vizPanel = getDefaultVizPanel();
|
||||
|
||||
|
||||
@@ -211,6 +211,25 @@ export function ToolbarActions({ dashboard }: Props) {
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
leftActions.push({
|
||||
group: 'add-panel',
|
||||
condition: isEditingAndShowingDashboard,
|
||||
render: () => (
|
||||
<Button
|
||||
key="add-tab-button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="plus"
|
||||
fill="text"
|
||||
onClick={() => {
|
||||
dashboard.onCreateNewTab();
|
||||
}}
|
||||
data-testid={selectors.components.PageToolbar.itemButton('add_tab')}
|
||||
>
|
||||
<Trans i18nKey="dashboard.toolbar.add-tab">Tab</Trans>
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
leftActions.push({
|
||||
group: 'add-panel',
|
||||
condition: isEditingAndShowingDashboard,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+28
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<RowsLayoutManagerState> 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<RowsLayoutManagerState> 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 {
|
||||
|
||||
@@ -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<TabItemState>
|
||||
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<TabItemState>) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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: () => <TabTitleInput tab={model} />,
|
||||
})
|
||||
);
|
||||
}, [model]);
|
||||
|
||||
const { layout } = model.useState();
|
||||
const layoutOptions = useLayoutCategory(layout);
|
||||
|
||||
return [tabOptions, layoutOptions];
|
||||
}
|
||||
|
||||
export function renderActions(tab: TabItem): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant="secondary" icon="copy" />
|
||||
<Button size="sm" variant="destructive" fill="outline" onClick={() => tab.onDelete()} icon="trash-alt" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TabTitleInput({ tab }: { tab: TabItem }) {
|
||||
const { title } = tab.useState();
|
||||
|
||||
return <Input value={title} onChange={(e) => tab.onChangeTitle(e.currentTarget.value)} />;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { SceneComponentProps, sceneGraph } from '@grafana/scenes';
|
||||
import { Tab, useElementSelection } from '@grafana/ui';
|
||||
|
||||
import { isClonedKey } from '../../utils/clone';
|
||||
import { getDashboardSceneFor } from '../../utils/utils';
|
||||
|
||||
import { TabItem } from './TabItem';
|
||||
|
||||
export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
|
||||
const { title, key } = model.useState();
|
||||
const isClone = useMemo(() => isClonedKey(key!), [key]);
|
||||
const parentLayout = model.getParentLayout();
|
||||
const { currentTab } = parentLayout.useState();
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
const { isEditing } = dashboard.useState();
|
||||
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
|
||||
const { isSelected, onSelect } = useElementSelection(key);
|
||||
|
||||
return (
|
||||
<Tab
|
||||
className={!isClone && isSelected ? 'dashboard-selected-element' : undefined}
|
||||
label={titleInterpolated}
|
||||
active={model === currentTab}
|
||||
onPointerDown={(evt) => {
|
||||
evt.stopPropagation();
|
||||
|
||||
if (isEditing) {
|
||||
if (isClone) {
|
||||
dashboard.state.editPane.clearSelection();
|
||||
} else {
|
||||
onSelect?.(evt);
|
||||
}
|
||||
}
|
||||
|
||||
parentLayout.changeTab(model);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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 { TabItem } from './TabItem';
|
||||
import { getEditOptions, renderActions } from './TabItemsEditor';
|
||||
|
||||
export class TabItems implements MultiSelectedEditableDashboardElement {
|
||||
public readonly isMultiSelectedEditableDashboardElement = true;
|
||||
public readonly typeName = 'Tabs';
|
||||
public readonly key: string;
|
||||
|
||||
public constructor(private _tabs: TabItem[]) {
|
||||
this.key = uuidv4();
|
||||
}
|
||||
|
||||
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
|
||||
return getEditOptions(this);
|
||||
}
|
||||
|
||||
public renderActions(): ReactNode {
|
||||
return renderActions(this);
|
||||
}
|
||||
|
||||
public getTabs(): TabItem[] {
|
||||
return this._tabs;
|
||||
}
|
||||
|
||||
public onDelete() {
|
||||
this._tabs.forEach((tab) => tab.onDelete());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Button, Stack, Text } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
|
||||
import { TabItems } from './TabItems';
|
||||
|
||||
export function getEditOptions(_model: TabItems): OptionsPaneCategoryDescriptor[] {
|
||||
const tabOptions = useMemo(() => {
|
||||
return new OptionsPaneCategoryDescriptor({
|
||||
title: t('dashboard.edit-pane.tab.multi-select.options-header', 'Multi-selected Tab options'),
|
||||
id: 'ms-tab-options',
|
||||
isOpenDefault: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return [tabOptions];
|
||||
}
|
||||
|
||||
export function renderActions(model: TabItems) {
|
||||
const tabs = model.getTabs();
|
||||
|
||||
return (
|
||||
<Stack direction="column">
|
||||
<Text>
|
||||
<Trans i18nKey="dashboard.edit-pane.tab.multi-select.selection-number">No. of tabs selected: </Trans>
|
||||
{tabs.length}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
|
||||
|
||||
import { TabItem } from './TabItem';
|
||||
import { TabsLayoutManagerRenderer } from './TabsLayoutManagerRenderer';
|
||||
|
||||
interface TabsLayoutManagerState extends SceneObjectState {
|
||||
tabs: TabItem[];
|
||||
currentTab: TabItem;
|
||||
}
|
||||
|
||||
export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> implements DashboardLayoutManager {
|
||||
public static Component = TabsLayoutManagerRenderer;
|
||||
|
||||
public readonly isDashboardLayoutManager = true;
|
||||
|
||||
public static readonly descriptor = {
|
||||
get name() {
|
||||
return t('dashboard.tabs-layout.name', 'Tabs');
|
||||
},
|
||||
get description() {
|
||||
return t('dashboard.tabs-layout.description', 'Tabs layout');
|
||||
},
|
||||
id: 'tabs-layout',
|
||||
createFromLayout: TabsLayoutManager.createFromLayout,
|
||||
};
|
||||
|
||||
public readonly descriptor = TabsLayoutManager.descriptor;
|
||||
|
||||
public addPanel(vizPanel: VizPanel) {
|
||||
this.state.currentTab.getLayout().addPanel(vizPanel);
|
||||
}
|
||||
|
||||
public getVizPanels(): VizPanel[] {
|
||||
const panels: VizPanel[] = [];
|
||||
|
||||
for (const tab of this.state.tabs) {
|
||||
const innerPanels = tab.getLayout().getVizPanels();
|
||||
panels.push(...innerPanels);
|
||||
}
|
||||
|
||||
return panels;
|
||||
}
|
||||
|
||||
public hasVizPanels(): boolean {
|
||||
for (const tab of this.state.tabs) {
|
||||
if (tab.getLayout().hasVizPanels()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public addNewRow() {
|
||||
this.state.currentTab.getLayout().addNewRow();
|
||||
}
|
||||
|
||||
public addNewTab() {
|
||||
const currentTab = new TabItem();
|
||||
this.setState({ tabs: [...this.state.tabs, currentTab], currentTab });
|
||||
}
|
||||
|
||||
public editModeChanged(isEditing: boolean) {
|
||||
this.state.tabs.forEach((tab) => tab.getLayout().editModeChanged?.(isEditing));
|
||||
}
|
||||
|
||||
public activateRepeaters() {
|
||||
this.state.tabs.forEach((tab) => tab.getLayout().activateRepeaters?.());
|
||||
}
|
||||
|
||||
public removeTab(tab: TabItem) {
|
||||
if (this.state.currentTab === tab) {
|
||||
const currentTabIndex = this.state.tabs.indexOf(tab);
|
||||
const nextTabIndex = currentTabIndex === 0 ? 1 : currentTabIndex - 1;
|
||||
const nextTab = this.state.tabs[nextTabIndex];
|
||||
this.setState({ tabs: this.state.tabs.filter((t) => t !== tab), currentTab: nextTab });
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredTab = this.state.tabs.filter((tab) => tab !== this.state.currentTab);
|
||||
const tabs = filteredTab.length === 0 ? [new TabItem()] : filteredTab;
|
||||
|
||||
this.setState({ tabs, currentTab: tabs[tabs.length - 1] });
|
||||
}
|
||||
|
||||
public changeTab(tab: TabItem) {
|
||||
this.setState({ currentTab: tab });
|
||||
}
|
||||
|
||||
public static createEmpty(): TabsLayoutManager {
|
||||
const tab = new TabItem();
|
||||
return new TabsLayoutManager({ tabs: [tab], currentTab: tab });
|
||||
}
|
||||
|
||||
public static createFromLayout(layout: DashboardLayoutManager): TabsLayoutManager {
|
||||
const tab = new TabItem({ layout: layout.clone() });
|
||||
return new TabsLayoutManager({ tabs: [tab], currentTab: tab });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { SceneComponentProps } from '@grafana/scenes';
|
||||
import { TabContent, TabsBar, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { TabsLayoutManager } from './TabsLayoutManager';
|
||||
|
||||
export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLayoutManager>) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { tabs, currentTab } = model.useState();
|
||||
const { layout } = currentTab.useState();
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabsBar className={styles.tabsContainer}>
|
||||
{tabs.map((tab) => (
|
||||
<tab.Component model={tab} key={tab.state.key!} />
|
||||
))}
|
||||
</TabsBar>
|
||||
<TabContent className={styles.tabContentContainer}>{layout && <layout.Component model={layout} />}</TabContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
tabsContainer: css({
|
||||
flexShrink: 1,
|
||||
padding: '2px 2px 0 2px',
|
||||
marginBottom: theme.spacing(1),
|
||||
}),
|
||||
tabContentContainer: css({
|
||||
backgroundColor: 'transparent',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
scrollbarWidth: 'thin',
|
||||
padding: '2px 2px 0 2px',
|
||||
}),
|
||||
});
|
||||
@@ -3,8 +3,14 @@ import { Registry } from '@grafana/data';
|
||||
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
|
||||
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
|
||||
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
|
||||
import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
|
||||
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
|
||||
|
||||
export const layoutRegistry: Registry<LayoutRegistryItem> = new Registry<LayoutRegistryItem>(() => {
|
||||
return [DefaultGridLayoutManager.descriptor, ResponsiveGridLayoutManager.descriptor, RowsLayoutManager.descriptor];
|
||||
return [
|
||||
DefaultGridLayoutManager.descriptor,
|
||||
ResponsiveGridLayoutManager.descriptor,
|
||||
RowsLayoutManager.descriptor,
|
||||
TabsLayoutManager.descriptor,
|
||||
];
|
||||
});
|
||||
@@ -34,15 +34,25 @@ export interface DashboardLayoutManager<S = {}> extends SceneObject {
|
||||
duplicatePanel?(panel: VizPanel): void;
|
||||
|
||||
/**
|
||||
* getVizPanels
|
||||
* Gets all the viz panels in the layout
|
||||
*/
|
||||
getVizPanels(): VizPanel[];
|
||||
|
||||
/**
|
||||
* Check if the layout has viz panels
|
||||
*/
|
||||
hasVizPanels(): boolean;
|
||||
|
||||
/**
|
||||
* Add row
|
||||
*/
|
||||
addNewRow(): void;
|
||||
|
||||
/**
|
||||
* Add tab
|
||||
*/
|
||||
addNewTab(): void;
|
||||
|
||||
/**
|
||||
* Notify the layout manager that the edit mode has changed
|
||||
* @param isEditing
|
||||
|
||||
@@ -64,7 +64,7 @@ export class V1DashboardSerializer implements DashboardSceneSerializerLike<Dashb
|
||||
id: null,
|
||||
uid: '',
|
||||
title: options.title || '',
|
||||
description: options.description || '',
|
||||
description: options.description || undefined,
|
||||
tags: options.isNew || options.copyTags ? saveModel.tags : [],
|
||||
};
|
||||
}
|
||||
|
||||
+5
-1
@@ -141,7 +141,11 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
|
||||
|
||||
const isDashboardEditable = Boolean(dashboard.editable);
|
||||
const canSave = dto.access.canSave !== false;
|
||||
const dashboardId = metadata.labels?.[DeprecatedInternalId];
|
||||
let dashboardId: number | undefined = undefined;
|
||||
|
||||
if (metadata.labels?.[DeprecatedInternalId]) {
|
||||
dashboardId = parseInt(metadata.labels[DeprecatedInternalId], 10);
|
||||
}
|
||||
|
||||
const meta: DashboardMeta = {
|
||||
canShare: dto.access.canShare !== false,
|
||||
|
||||
@@ -428,7 +428,7 @@ describe('ResponseTransformers', () => {
|
||||
[AnnoKeySlug]: 'dashboard-slug',
|
||||
},
|
||||
labels: {
|
||||
[DeprecatedInternalId]: 123,
|
||||
[DeprecatedInternalId]: '123',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -444,7 +444,7 @@ describe('ResponseTransformers', () => {
|
||||
expect(transformed.metadata.annotations?.[AnnoKeyFolder]).toEqual('folder1');
|
||||
expect(transformed.metadata.annotations?.[AnnoKeySlug]).toEqual('dashboard-slug');
|
||||
expect(transformed.metadata.annotations?.[AnnoKeyDashboardGnetId]).toBe('something-like-a-uid');
|
||||
expect(transformed.metadata.labels?.[DeprecatedInternalId]).toBe(123);
|
||||
expect(transformed.metadata.labels?.[DeprecatedInternalId]).toBe('123');
|
||||
|
||||
// Spec
|
||||
const spec = transformed.spec;
|
||||
|
||||
@@ -134,7 +134,7 @@ export function ensureV2Response(
|
||||
};
|
||||
creationTimestamp = dto.meta.created;
|
||||
labelsMeta = {
|
||||
[DeprecatedInternalId]: dashboard.id ?? undefined,
|
||||
[DeprecatedInternalId]: dashboard.id?.toString() ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
AnnoKeyMessage,
|
||||
AnnoKeyFolder,
|
||||
Resource,
|
||||
DeprecatedInternalId,
|
||||
} from 'app/features/apiserver/types';
|
||||
import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl';
|
||||
import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types';
|
||||
@@ -107,6 +108,10 @@ export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
|
||||
dashboard: dash.spec,
|
||||
};
|
||||
|
||||
if (dash.metadata.labels?.[DeprecatedInternalId]) {
|
||||
result.dashboard.id = parseInt(dash.metadata.labels[DeprecatedInternalId], 10);
|
||||
}
|
||||
|
||||
if (dash.metadata.annotations?.[AnnoKeyFolder]) {
|
||||
try {
|
||||
const folder = await backendSrv.getFolderByUid(dash.metadata.annotations[AnnoKeyFolder]);
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('v2 dashboard API - Save', () => {
|
||||
k8s: {
|
||||
name: 'test-dash',
|
||||
labels: {
|
||||
[DeprecatedInternalId]: 123,
|
||||
[DeprecatedInternalId]: '123',
|
||||
},
|
||||
|
||||
annotations: {
|
||||
|
||||
@@ -143,10 +143,15 @@ export class K8sDashboardV2API
|
||||
})
|
||||
);
|
||||
|
||||
let dashId = 0;
|
||||
if (v.metadata.labels?.[DeprecatedInternalId]) {
|
||||
dashId = parseInt(v.metadata.labels[DeprecatedInternalId], 10);
|
||||
}
|
||||
|
||||
return {
|
||||
uid: v.metadata.name,
|
||||
version: parseInt(v.metadata.resourceVersion, 10) ?? 0,
|
||||
id: v.metadata.labels?.[DeprecatedInternalId] ?? 0,
|
||||
id: dashId,
|
||||
status: 'success',
|
||||
url,
|
||||
slug: '',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { useAsyncFn } from 'react-use';
|
||||
|
||||
import { locationUtil } from '@grafana/data';
|
||||
@@ -46,6 +47,8 @@ export const useDashboardSave = (isCopy = false) => {
|
||||
const result = await saveDashboard(clone, options, dashboard, saveDashboardRtkQuery);
|
||||
dashboard.version = result.version;
|
||||
|
||||
// Altering the clone leads to an error due to the clone being immutable
|
||||
clone = cloneDeep(clone);
|
||||
clone.version = result.version;
|
||||
dashboard.clearUnsavedChanges(clone, options);
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ const getStyles = (theme: GrafanaTheme2, placeholder: string) => {
|
||||
'.monaco-editor .suggest-widget': {
|
||||
minWidth: '50%',
|
||||
},
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
placeholder: css({
|
||||
'::after': {
|
||||
|
||||
@@ -6,11 +6,6 @@ export enum SortOrder {
|
||||
TimeDesc,
|
||||
}
|
||||
|
||||
export enum ShowOption {
|
||||
Current = 'current',
|
||||
RecentChanges = 'changes',
|
||||
}
|
||||
|
||||
export enum GroupMode {
|
||||
Default = 'default',
|
||||
Custom = 'custom',
|
||||
@@ -21,26 +16,6 @@ export enum ViewMode {
|
||||
Stat = 'stat',
|
||||
}
|
||||
|
||||
export interface AlertListOptions {
|
||||
showOptions: ShowOption;
|
||||
maxItems: number;
|
||||
sortOrder: SortOrder;
|
||||
dashboardAlerts: boolean;
|
||||
alertName: string;
|
||||
dashboardTitle: string;
|
||||
tags: string[];
|
||||
stateFilter: {
|
||||
ok: boolean;
|
||||
paused: boolean;
|
||||
no_data: boolean;
|
||||
execution_error: boolean;
|
||||
alerting: boolean;
|
||||
pending: boolean;
|
||||
};
|
||||
folderId: number;
|
||||
showInactiveAlerts: boolean;
|
||||
}
|
||||
|
||||
export interface StateFilter {
|
||||
firing: boolean;
|
||||
pending: boolean;
|
||||
|
||||
@@ -969,6 +969,12 @@
|
||||
"options-header": "Multi-selected Row options",
|
||||
"selection-number": "No. of rows selected: {{length}}"
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
"multi-select": {
|
||||
"options-header": "Multi-selected Tab options",
|
||||
"selection-number": "No. of tabs selected: "
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
@@ -1094,11 +1100,23 @@
|
||||
"title-option": "Title"
|
||||
}
|
||||
},
|
||||
"tabs-layout": {
|
||||
"description": "Tabs layout",
|
||||
"name": "Tabs",
|
||||
"tab": {
|
||||
"new": "New tab"
|
||||
},
|
||||
"tab-options": {
|
||||
"title": "Tab options",
|
||||
"title-option": "Title"
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
"add": "Add",
|
||||
"add-panel": "Panel",
|
||||
"add-panel-lib": "Import",
|
||||
"add-row": "Row",
|
||||
"add-tab": "Tab",
|
||||
"alert-rules": "Alert rules",
|
||||
"back-to-dashboard": "Back to dashboard",
|
||||
"dashboard-settings": {
|
||||
|
||||
@@ -969,6 +969,12 @@
|
||||
"options-header": "Mūľŧį-şęľęčŧęđ Ŗőŵ őpŧįőʼnş",
|
||||
"selection-number": "Ńő. őƒ řőŵş şęľęčŧęđ: {{length}}"
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
"multi-select": {
|
||||
"options-header": "Mūľŧį-şęľęčŧęđ Ŧäþ őpŧįőʼnş",
|
||||
"selection-number": "Ńő. őƒ ŧäþş şęľęčŧęđ: "
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
@@ -1094,11 +1100,23 @@
|
||||
"title-option": "Ŧįŧľę"
|
||||
}
|
||||
},
|
||||
"tabs-layout": {
|
||||
"description": "Ŧäþş ľäyőūŧ",
|
||||
"name": "Ŧäþş",
|
||||
"tab": {
|
||||
"new": "Ńęŵ ŧäþ"
|
||||
},
|
||||
"tab-options": {
|
||||
"title": "Ŧäþ őpŧįőʼnş",
|
||||
"title-option": "Ŧįŧľę"
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
"add": "Åđđ",
|
||||
"add-panel": "Päʼnęľ",
|
||||
"add-panel-lib": "Ĩmpőřŧ",
|
||||
"add-row": "Ŗőŵ",
|
||||
"add-tab": "Ŧäþ",
|
||||
"alert-rules": "Åľęřŧ řūľęş",
|
||||
"back-to-dashboard": "ßäčĸ ŧő đäşĥþőäřđ",
|
||||
"dashboard-settings": {
|
||||
|
||||
@@ -15,3 +15,7 @@ type=sqlite3
|
||||
wal=true
|
||||
max_idle_conn = 2
|
||||
max_open_conn = 2
|
||||
|
||||
[smtp]
|
||||
enabled = true
|
||||
host = localhost:7777
|
||||
@@ -48,6 +48,11 @@ elif [ -d "../e2e/test-plugins" ]; then
|
||||
cp -r "../e2e/test-plugins" "$RUNDIR/data/plugins"
|
||||
fi
|
||||
|
||||
if [ "$INSTALL_IMAGE_RENDERER" ]; then
|
||||
echo -e "Installing image renderer"
|
||||
$RUNDIR/bin/"$ARCH"grafana cli --pluginsDir "$RUNDIR/data/plugins" plugins install grafana-image-renderer
|
||||
fi
|
||||
|
||||
echo -e "Copy provisioning setup from devenv"
|
||||
|
||||
cp devenv/datasources.yaml $PROV_DIR/datasources
|
||||
|
||||
Reference in new issue
Block a user