Provisioning: return field paths in test error messages (#103850)

* Provisioning: Do not block connect step on error

* Display field errors

* Cleanup

* return field errors

* fix test

* convert errros to an array

* Fix history display

* Add getFormErrors

* metav1 issues

* lint

* Proper field names

* Fix notification

* Remove unused component

---------

Co-authored-by: Clarity-89 <homes89@ukr.net>
pull/103818/head
Ryan McKinley 3 months ago committed by GitHub
parent ed9a7e8d9f
commit 2c3422fc5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 11
      pkg/apis/provisioning/v0alpha1/types.go
  2. 22
      pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go
  3. 48
      pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go
  4. 14
      pkg/registry/apis/provisioning/controller/repository.go
  5. 2
      pkg/registry/apis/provisioning/register.go
  6. 72
      pkg/registry/apis/provisioning/repository/github.go
  7. 25
      pkg/registry/apis/provisioning/repository/local.go
  8. 23
      pkg/registry/apis/provisioning/repository/test.go
  9. 21
      pkg/registry/apis/provisioning/repository/test_test.go
  10. 12
      pkg/registry/apis/provisioning/test.go
  11. 36
      pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json
  12. 11
      public/app/api/clients/provisioning/endpoints.gen.ts
  13. 9
      public/app/api/clients/provisioning/index.ts
  14. 16
      public/app/features/provisioning/Job/ActiveJobStatus.tsx
  15. 9
      public/app/features/provisioning/Job/JobStatus.tsx
  16. 31
      public/app/features/provisioning/Job/hooks.ts
  17. 49
      public/app/features/provisioning/Wizard/ProvisioningWizard.tsx
  18. 40
      public/app/features/provisioning/Wizard/RequestErrorAlert.tsx
  19. 5
      public/app/features/provisioning/Wizard/SynchronizeStep.tsx
  20. 39
      public/app/features/provisioning/utils/getFormErrors.ts
  21. 10
      public/locales/en-US/grafana.json

@ -394,11 +394,14 @@ type TestResults struct {
// Is the connection healthy // Is the connection healthy
Success bool `json:"success"` Success bool `json:"success"`
// Error descriptions // Field related errors
Errors []string `json:"errors,omitempty"` Errors []ErrorDetails `json:"errors,omitempty"`
}
// Optional details type ErrorDetails struct {
Details *common.Unstructured `json:"details,omitempty"` Type metav1.CauseType `json:"type"`
Field string `json:"field,omitempty"`
Detail string `json:"detail,omitempty"`
} }
// HistoryList is a list of versions of a resource // HistoryList is a list of versions of a resource

@ -27,6 +27,22 @@ func (in *Author) DeepCopy() *Author {
return out return out
} }
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ErrorDetails) DeepCopyInto(out *ErrorDetails) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ErrorDetails.
func (in *ErrorDetails) DeepCopy() *ErrorDetails {
if in == nil {
return nil
}
out := new(ErrorDetails)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ExportJobOptions) DeepCopyInto(out *ExportJobOptions) { func (in *ExportJobOptions) DeepCopyInto(out *ExportJobOptions) {
*out = *in *out = *in
@ -849,13 +865,9 @@ func (in *TestResults) DeepCopyInto(out *TestResults) {
out.TypeMeta = in.TypeMeta out.TypeMeta = in.TypeMeta
if in.Errors != nil { if in.Errors != nil {
in, out := &in.Errors, &out.Errors in, out := &in.Errors, &out.Errors
*out = make([]string, len(*in)) *out = make([]ErrorDetails, len(*in))
copy(*out, *in) copy(*out, *in)
} }
if in.Details != nil {
in, out := &in.Details, &out.Details
*out = (*in).DeepCopy()
}
return return
} }

@ -15,6 +15,7 @@ import (
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
return map[string]common.OpenAPIDefinition{ return map[string]common.OpenAPIDefinition{
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.Author": schema_pkg_apis_provisioning_v0alpha1_Author(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.Author": schema_pkg_apis_provisioning_v0alpha1_Author(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ErrorDetails": schema_pkg_apis_provisioning_v0alpha1_ErrorDetails(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ExportJobOptions": schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ExportJobOptions": schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.FileItem": schema_pkg_apis_provisioning_v0alpha1_FileItem(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.FileItem": schema_pkg_apis_provisioning_v0alpha1_FileItem(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.FileList": schema_pkg_apis_provisioning_v0alpha1_FileList(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.FileList": schema_pkg_apis_provisioning_v0alpha1_FileList(ref),
@ -88,6 +89,38 @@ func schema_pkg_apis_provisioning_v0alpha1_Author(ref common.ReferenceCallback)
} }
} }
func schema_pkg_apis_provisioning_v0alpha1_ErrorDetails(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"type": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"field": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
"detail": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"type"},
},
},
}
}
func schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { func schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{ return common.OpenAPIDefinition{
Schema: spec.Schema{ Schema: spec.Schema{
@ -1909,31 +1942,24 @@ func schema_pkg_apis_provisioning_v0alpha1_TestResults(ref common.ReferenceCallb
}, },
"errors": { "errors": {
SchemaProps: spec.SchemaProps{ SchemaProps: spec.SchemaProps{
Description: "Error descriptions", Description: "Field related errors",
Type: []string{"array"}, Type: []string{"array"},
Items: &spec.SchemaOrArray{ Items: &spec.SchemaOrArray{
Schema: &spec.Schema{ Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{ SchemaProps: spec.SchemaProps{
Default: "", Default: map[string]interface{}{},
Type: []string{"string"}, Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ErrorDetails"),
Format: "",
}, },
}, },
}, },
}, },
}, },
"details": {
SchemaProps: spec.SchemaProps{
Description: "Optional details",
Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"),
},
},
}, },
Required: []string{"code", "success"}, Required: []string{"code", "success"},
}, },
}, },
Dependencies: []string{ Dependencies: []string{
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"}, "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ErrorDetails"},
} }
} }

@ -271,18 +271,22 @@ func (rc *RepositoryController) runHealthCheck(ctx context.Context, repo reposit
if err != nil { if err != nil {
res = &provisioning.TestResults{ res = &provisioning.TestResults{
Success: false, Success: false,
Errors: []string{ Errors: []provisioning.ErrorDetails{{
"error running test repository", Detail: fmt.Sprintf("error running test repository: %s", err.Error()),
err.Error(), }},
},
} }
} }
healthStatus := provisioning.HealthStatus{ healthStatus := provisioning.HealthStatus{
Healthy: res.Success, Healthy: res.Success,
Checked: time.Now().UnixMilli(), Checked: time.Now().UnixMilli(),
Message: res.Errors,
} }
for _, err := range res.Errors {
if err.Detail != "" {
healthStatus.Message = append(healthStatus.Message, err.Detail)
}
}
logger.Info("health check completed", "status", healthStatus) logger.Info("health check completed", "status", healthStatus)
return healthStatus return healthStatus

@ -1114,7 +1114,7 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor
return gogit.Clone(ctx, b.clonedir, r, opts, b.secrets) return gogit.Clone(ctx, b.clonedir, r, opts, b.secrets)
} }
return repository.NewGitHub(ctx, r, b.ghFactory, b.secrets, webhookURL, cloneFn) return repository.NewGitHub(ctx, r, b.ghFactory, b.secrets, webhookURL, cloneFn), nil
default: default:
return nil, fmt.Errorf("unknown repository type (%s)", r.Spec.Type) return nil, fmt.Errorf("unknown repository type (%s)", r.Spec.Type)
} }

@ -12,12 +12,11 @@ import (
"strings" "strings"
"github.com/google/go-github/v70/github" "github.com/google/go-github/v70/github"
"github.com/google/uuid"
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/util/validation/field"
"github.com/google/uuid"
"github.com/grafana/grafana-app-sdk/logging" "github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github" pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
@ -57,18 +56,14 @@ func NewGitHub(
secrets secrets.Service, secrets secrets.Service,
webhookURL string, webhookURL string,
cloneFn CloneFn, cloneFn CloneFn,
) (*githubRepository, error) { ) *githubRepository {
owner, repo, err := parseOwnerRepo(config.Spec.GitHub.URL) owner, repo, _ := parseOwnerRepo(config.Spec.GitHub.URL)
if err != nil {
return nil, err
}
token := config.Spec.GitHub.Token token := config.Spec.GitHub.Token
if token == "" { if token == "" {
decrypted, err := secrets.Decrypt(ctx, config.Spec.GitHub.EncryptedToken) decrypted, err := secrets.Decrypt(ctx, config.Spec.GitHub.EncryptedToken)
if err != nil { if err == nil {
return nil, err token = string(decrypted)
} }
token = string(decrypted)
} }
return &githubRepository{ return &githubRepository{
config: config, config: config,
@ -78,7 +73,7 @@ func NewGitHub(
owner: owner, owner: owner,
repo: repo, repo: repo,
cloneFn: cloneFn, cloneFn: cloneFn,
}, nil }
} }
func (r *githubRepository) Config() *provisioning.Repository { func (r *githubRepository) Config() *provisioning.Repository {
@ -138,59 +133,48 @@ func parseOwnerRepo(giturl string) (owner string, repo string, err error) {
return parts[1], parts[2], nil return parts[1], parts[2], nil
} }
func fromError(err error, code int) *provisioning.TestResults {
statusErr, ok := err.(apierrors.APIStatus)
if ok {
s := statusErr.Status()
return &provisioning.TestResults{
Code: int(s.Code),
Success: false,
Errors: []string{s.Message},
}
}
return &provisioning.TestResults{
Code: code,
Success: false,
Errors: []string{err.Error()},
}
}
// Test implements provisioning.Repository. // Test implements provisioning.Repository.
func (r *githubRepository) Test(ctx context.Context) (*provisioning.TestResults, error) { func (r *githubRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
if err := r.gh.IsAuthenticated(ctx); err != nil { if err := r.gh.IsAuthenticated(ctx); err != nil {
return fromError(err, http.StatusUnauthorized), nil return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("spec", "github", "token").String(),
Detail: err.Error(),
}}}, nil
} }
owner, repo, err := parseOwnerRepo(r.config.Spec.GitHub.URL) url := r.config.Spec.GitHub.URL
owner, repo, err := parseOwnerRepo(url)
if err != nil { if err != nil {
return fromError(err, http.StatusBadRequest), nil return fromFieldError(field.Invalid(
field.NewPath("spec", "github", "url"), url, err.Error())), nil
} }
// FIXME: check token permissions // FIXME: check token permissions
ok, err := r.gh.RepoExists(ctx, owner, repo) ok, err := r.gh.RepoExists(ctx, owner, repo)
if err != nil { if err != nil {
return fromError(err, http.StatusBadRequest), nil return fromFieldError(field.Invalid(
field.NewPath("spec", "github", "url"), url, err.Error())), nil
} }
if !ok { if !ok {
return &provisioning.TestResults{ return fromFieldError(field.NotFound(
Code: http.StatusBadRequest, field.NewPath("spec", "github", "url"), url)), nil
Success: false,
Errors: []string{"repository does not exist"},
}, nil
} }
ok, err = r.gh.BranchExists(ctx, r.owner, r.repo, r.config.Spec.GitHub.Branch) branch := r.config.Spec.GitHub.Branch
ok, err = r.gh.BranchExists(ctx, r.owner, r.repo, branch)
if err != nil { if err != nil {
return fromError(err, http.StatusBadRequest), nil return fromFieldError(field.Invalid(
field.NewPath("spec", "github", "branch"), branch, err.Error())), nil
} }
if !ok { if !ok {
return &provisioning.TestResults{ return fromFieldError(field.NotFound(
Code: http.StatusBadRequest, field.NewPath("spec", "github", "branch"), branch)), nil
Success: false,
Errors: []string{"branch does not exist"},
}, nil
} }
return &provisioning.TestResults{ return &provisioning.TestResults{

@ -146,36 +146,19 @@ func (r *localRepository) Validate() (fields field.ErrorList) {
// Test implements provisioning.Repository. // Test implements provisioning.Repository.
// NOTE: Validate has been called (and passed) before this function should be called // NOTE: Validate has been called (and passed) before this function should be called
func (r *localRepository) Test(ctx context.Context) (*provisioning.TestResults, error) { func (r *localRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
path := field.NewPath("spec", "localhost", "path")
if r.config.Spec.Local.Path == "" { if r.config.Spec.Local.Path == "" {
return &provisioning.TestResults{ return fromFieldError(field.Required(path, "no path is configured")), nil
Code: http.StatusBadRequest,
Success: false,
Errors: []string{
"no path is configured",
},
}, nil
} }
_, err := r.resolver.LocalPath(r.config.Spec.Local.Path) _, err := r.resolver.LocalPath(r.config.Spec.Local.Path)
if err != nil { if err != nil {
return &provisioning.TestResults{ return fromFieldError(field.Invalid(path, r.config.Spec.Local.Path, err.Error())), nil
Code: http.StatusBadRequest,
Success: false,
Errors: []string{
err.Error(),
},
}, nil
} }
_, err = os.Stat(r.path) _, err = os.Stat(r.path)
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return &provisioning.TestResults{ return fromFieldError(field.NotFound(path, r.config.Spec.Local.Path)), nil
Code: http.StatusBadRequest,
Success: false,
Errors: []string{
fmt.Sprintf("directory not found: %s", r.config.Spec.Local.Path),
},
}, nil
} }
return &provisioning.TestResults{ return &provisioning.TestResults{

@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"slices" "slices"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
@ -26,10 +27,14 @@ func TestRepository(ctx context.Context, repo Repository) (*provisioning.TestRes
rsp := &provisioning.TestResults{ rsp := &provisioning.TestResults{
Code: http.StatusUnprocessableEntity, // Invalid Code: http.StatusUnprocessableEntity, // Invalid
Success: false, Success: false,
Errors: make([]string, len(errors)), Errors: make([]provisioning.ErrorDetails, len(errors)),
} }
for i, v := range errors { for i, err := range errors {
rsp.Errors[i] = v.Error() rsp.Errors[i] = provisioning.ErrorDetails{
Type: metav1.CauseType(err.Type),
Field: err.Field,
Detail: err.Detail,
}
} }
return rsp, nil return rsp, nil
} }
@ -85,3 +90,15 @@ func ValidateRepository(repo Repository) field.ErrorList {
return list return list
} }
func fromFieldError(err *field.Error) *provisioning.TestResults {
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []provisioning.ErrorDetails{{
Type: metav1.CauseType(err.Type),
Field: err.Field,
Detail: err.Detail,
}},
}
}

@ -6,11 +6,12 @@ import (
"net/http" "net/http"
"testing" "testing"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
) )
func TestValidateRepository(t *testing.T) { func TestValidateRepository(t *testing.T) {
@ -192,7 +193,7 @@ func TestTestRepository(t *testing.T) {
name string name string
repository *MockRepository repository *MockRepository
expectedCode int expectedCode int
expectedErrs []string expectedErrs []provisioning.ErrorDetails
expectedError error expectedError error
}{ }{
{ {
@ -208,7 +209,11 @@ func TestTestRepository(t *testing.T) {
return m return m
}(), }(),
expectedCode: http.StatusUnprocessableEntity, expectedCode: http.StatusUnprocessableEntity,
expectedErrs: []string{"spec.title: Required value: a repository title must be given"}, expectedErrs: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueRequired,
Field: "spec.title",
Detail: "a repository title must be given",
}},
}, },
{ {
name: "test passes", name: "test passes",
@ -257,12 +262,18 @@ func TestTestRepository(t *testing.T) {
m.On("Test", mock.Anything).Return(&provisioning.TestResults{ m.On("Test", mock.Anything).Return(&provisioning.TestResults{
Code: http.StatusBadRequest, Code: http.StatusBadRequest,
Success: false, Success: false,
Errors: []string{"test failed"}, Errors: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueInvalid,
Field: "spec.property",
}},
}, nil) }, nil)
return m return m
}(), }(),
expectedCode: http.StatusBadRequest, expectedCode: http.StatusBadRequest,
expectedErrs: []string{"test failed"}, expectedErrs: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueInvalid,
Field: "spec.property",
}},
}, },
} }

@ -99,9 +99,9 @@ func (t *RepositoryTester) UpdateHealthStatus(ctx context.Context, cfg *provisio
if res == nil { if res == nil {
res = &provisioning.TestResults{ res = &provisioning.TestResults{
Success: false, Success: false,
Errors: []string{ Errors: []provisioning.ErrorDetails{{
"missing health status", Detail: "missing health status",
}, }},
} }
} }
@ -109,7 +109,11 @@ func (t *RepositoryTester) UpdateHealthStatus(ctx context.Context, cfg *provisio
repo.Status.Health = provisioning.HealthStatus{ repo.Status.Health = provisioning.HealthStatus{
Healthy: res.Success, Healthy: res.Success,
Checked: time.Now().UnixMilli(), Checked: time.Now().UnixMilli(),
Message: res.Errors, }
for _, err := range res.Errors {
if err.Detail != "" {
repo.Status.Health.Message = append(repo.Status.Health.Message, err.Detail)
}
} }
_, err := t.client.Repositories(repo.GetNamespace()). _, err := t.client.Repositories(repo.GetNamespace()).

@ -2472,6 +2472,24 @@
} }
} }
}, },
"com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ErrorDetails": {
"type": "object",
"required": [
"type"
],
"properties": {
"detail": {
"type": "string"
},
"field": {
"type": "string"
},
"type": {
"type": "string",
"default": ""
}
}
},
"com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ExportJobOptions": { "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ExportJobOptions": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -3762,20 +3780,16 @@
"format": "int32", "format": "int32",
"default": 0 "default": 0
}, },
"details": {
"description": "Optional details",
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apimachinery.apis.common.v0alpha1.Unstructured"
}
]
},
"errors": { "errors": {
"description": "Error descriptions", "description": "Field related errors",
"type": "array", "type": "array",
"items": { "items": {
"type": "string", "default": {},
"default": "" "allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ErrorDetails"
}
]
} }
}, },
"kind": { "kind": {

@ -1096,15 +1096,18 @@ export type ResourceList = {
kind?: string; kind?: string;
metadata?: ListMeta; metadata?: ListMeta;
}; };
export type ErrorDetails = {
detail?: string;
field?: string;
type: string;
};
export type TestResults = { export type TestResults = {
/** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */
apiVersion?: string; apiVersion?: string;
/** HTTP status code */ /** HTTP status code */
code: number; code: number;
/** Optional details */ /** Field related errors */
details?: Unstructured; errors?: ErrorDetails[];
/** Error descriptions */
errors?: string[];
/** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
kind?: string; kind?: string;
/** Is the connection healthy */ /** Is the connection healthy */

@ -13,6 +13,7 @@ import {
JobList, JobList,
Repository, Repository,
RepositoryList, RepositoryList,
ErrorDetails,
} from './endpoints.gen'; } from './endpoints.gen';
import { createOnCacheEntryAdded } from './utils/createOnCacheEntryAdded'; import { createOnCacheEntryAdded } from './utils/createOnCacheEntryAdded';
@ -100,9 +101,11 @@ export const provisioningAPI = generatedAPI.enhanceEndpoints({
dispatch(notifyApp(createErrorNotification('Error validating repository', e))); dispatch(notifyApp(createErrorNotification('Error validating repository', e)));
} else if (typeof e === 'object' && 'error' in e && isFetchError(e.error)) { } else if (typeof e === 'object' && 'error' in e && isFetchError(e.error)) {
if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) { if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) {
dispatch( const nonFieldErrors = e.error.data.errors.filter((err: ErrorDetails) => !err.field);
notifyApp(createErrorNotification('Error validating repository', e.error.data.errors.join('\n'))) // Only show notification if there are errors that don't have a field, field errors are handled by the form
); if (nonFieldErrors.length > 0) {
dispatch(notifyApp(createErrorNotification('Error validating repository')));
}
} }
} }
} }

@ -1,16 +0,0 @@
import { Job } from 'app/api/clients/provisioning';
import { JobContent } from './JobContent';
import { useJobStatusEffect } from './hooks';
export interface ActiveJobProps {
job: Job;
onStatusChange?: (success: boolean) => void;
onRunningChange?: (isRunning: boolean) => void;
onErrorChange?: (error: string | null) => void;
}
export function ActiveJobStatus({ job, onStatusChange, onRunningChange, onErrorChange }: ActiveJobProps) {
useJobStatusEffect(job, onStatusChange, onRunningChange, onErrorChange);
return <JobContent job={job} isFinishedJob={false} />;
}

@ -4,8 +4,8 @@ import { Trans } from 'app/core/internationalization';
import { StepStatusInfo } from '../Wizard/types'; import { StepStatusInfo } from '../Wizard/types';
import { ActiveJobStatus } from './ActiveJobStatus';
import { FinishedJobStatus } from './FinishedJobStatus'; import { FinishedJobStatus } from './FinishedJobStatus';
import { JobContent } from './JobContent';
export interface JobStatusProps { export interface JobStatusProps {
watch: Job; watch: Job;
@ -35,8 +35,13 @@ export function JobStatus({ watch, onStatusChange }: JobStatusProps) {
); );
} }
if (activeQuery.isError) {
onStatusChange({ status: 'error', error: 'Error fetching active job' });
return null;
}
if (activeJob) { if (activeJob) {
return <ActiveJobStatus job={activeJob} />; return <JobContent job={activeJob} isFinishedJob={false} />;
} }
if (shouldCheckFinishedJobs) { if (shouldCheckFinishedJobs) {

@ -1,31 +0,0 @@
import { useEffect } from 'react';
import { Job } from 'app/api/clients/provisioning';
import { t } from 'app/core/internationalization';
// Shared hook for status change effects
export function useJobStatusEffect(
job?: Job,
onStatusChange?: (success: boolean) => void,
onRunningChange?: (isRunning: boolean) => void,
onErrorChange?: (error: string | null) => void
) {
useEffect(() => {
if (!job) {
return;
}
if (onStatusChange && job.status?.state === 'success') {
onStatusChange(true);
if (onRunningChange) {
onRunningChange(false);
}
}
if (onErrorChange && job.status?.state === 'error') {
onErrorChange(job.status.message ?? t('provisioning.job-status.error-unknown', 'An unknown error occurred'));
if (onRunningChange) {
onRunningChange(false);
}
}
}, [job, onStatusChange, onErrorChange, onRunningChange]);
}

@ -4,7 +4,7 @@ import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom-v5-compat'; import { useNavigate } from 'react-router-dom-v5-compat';
import { AppEvents, GrafanaTheme2 } from '@grafana/data'; import { AppEvents, GrafanaTheme2 } from '@grafana/data';
import { getAppEvents } from '@grafana/runtime'; import { getAppEvents, isFetchError } from '@grafana/runtime';
import { Alert, Box, Button, Stack, Text, useStyles2 } from '@grafana/ui'; import { Alert, Box, Button, Stack, Text, useStyles2 } from '@grafana/ui';
import { useDeleteRepositoryMutation, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning'; import { useDeleteRepositoryMutation, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning';
import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt'; import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt';
@ -14,11 +14,11 @@ import { getDefaultValues } from '../Config/defaults';
import { PROVISIONING_URL } from '../constants'; import { PROVISIONING_URL } from '../constants';
import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository'; import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository';
import { dataToSpec } from '../utils/data'; import { dataToSpec } from '../utils/data';
import { getFormErrors } from '../utils/getFormErrors';
import { BootstrapStep } from './BootstrapStep'; import { BootstrapStep } from './BootstrapStep';
import { ConnectStep } from './ConnectStep'; import { ConnectStep } from './ConnectStep';
import { FinishStep } from './FinishStep'; import { FinishStep } from './FinishStep';
import { RequestErrorAlert } from './RequestErrorAlert';
import { Step, Stepper } from './Stepper'; import { Step, Stepper } from './Stepper';
import { SynchronizeStep } from './SynchronizeStep'; import { SynchronizeStep } from './SynchronizeStep';
import { RepoType, StepStatusInfo, WizardFormData, WizardStep } from './types'; import { RepoType, StepStatusInfo, WizardFormData, WizardStep } from './types';
@ -83,11 +83,12 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
setValue, setValue,
getValues, getValues,
trigger, trigger,
setError,
formState: { isDirty }, formState: { isDirty },
} = methods; } = methods;
const repoName = watch('repositoryName'); const repoName = watch('repositoryName');
const [submitData, saveRequest] = useCreateOrUpdateRepository(repoName); const [submitData] = useCreateOrUpdateRepository(repoName);
const [deleteRepository] = useDeleteRepositoryMutation(); const [deleteRepository] = useDeleteRepositoryMutation();
const currentStepIndex = steps.findIndex((s) => s.id === activeStep); const currentStepIndex = steps.findIndex((s) => s.id === activeStep);
@ -191,10 +192,17 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
console.error('Saved repository without a name:', rsp); console.error('Saved repository without a name:', rsp);
} }
} catch (error) { } catch (error) {
setStepStatusInfo({ if (isFetchError(error)) {
status: 'error', const [field, errorMessage] = getFormErrors(error.data.errors);
error: 'Repository connection failed', if (field && errorMessage) {
}); setError(field, errorMessage);
}
} else {
setStepStatusInfo({
status: 'error',
error: 'Repository connection failed',
});
}
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -210,7 +218,12 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
if (activeStep === 'synchronize') { if (activeStep === 'synchronize') {
return stepStatusInfo.status !== 'success'; return stepStatusInfo.status !== 'success';
} }
return isSubmitting || isCancelling || stepStatusInfo.status === 'running' || stepStatusInfo.status === 'error'; return (
isSubmitting ||
isCancelling ||
stepStatusInfo.status === 'running' ||
(activeStep !== 'connection' && stepStatusInfo.status === 'error')
);
}; };
return ( return (
@ -228,13 +241,9 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
</Text> </Text>
</Box> </Box>
<RequestErrorAlert {stepStatusInfo.status === 'error' && (
request={saveRequest} <Alert severity="error" title={'error' in stepStatusInfo ? stepStatusInfo.error : ''} />
title={t( )}
'provisioning.wizard-content.title-repository-verification-failed',
'Repository verification failed'
)}
/>
<div className={styles.content}> <div className={styles.content}>
{activeStep === 'connection' && <ConnectStep />} {activeStep === 'connection' && <ConnectStep />}
@ -252,16 +261,8 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
{activeStep === 'finish' && <FinishStep />} {activeStep === 'finish' && <FinishStep />}
</div> </div>
{stepStatusInfo.status === 'error' && (
<Alert severity="error" title={'error' in stepStatusInfo ? stepStatusInfo.error : ''} />
)}
<Stack gap={2} justifyContent="flex-end"> <Stack gap={2} justifyContent="flex-end">
<Button <Button variant={'secondary'} onClick={handleCancel} disabled={isSubmitting || isCancelling}>
variant={stepStatusInfo.status === 'error' ? 'primary' : 'secondary'}
onClick={handleCancel}
disabled={isSubmitting || isCancelling}
>
{isCancelling {isCancelling
? t('provisioning.wizard-content.button-cancelling', 'Cancelling...') ? t('provisioning.wizard-content.button-cancelling', 'Cancelling...')
: t('provisioning.wizard-content.button-cancel', 'Cancel')} : t('provisioning.wizard-content.button-cancel', 'Cancel')}

@ -1,40 +0,0 @@
import { Alert } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { getMessageFromError } from 'app/core/utils/errors';
interface RequestErrorAlertProps {
request?: {
isError: boolean;
error?: unknown;
endpointName?: string;
} | null;
title?: string;
}
function getDefaultTitle(endpointName?: string): string {
switch (endpointName) {
case 'createRepositorySync':
return t('provisioning.request-error.failed-to-sync', 'Failed to sync dashboards');
case 'createRepositoryMigrate':
return t('provisioning.request-error.failed-to-migrate', 'Failed to migrate dashboards');
case 'createOrUpdateRepository':
return t('provisioning.request-error.failed-to-save', 'Failed to save repository');
default:
return t('provisioning.request-error.operation-failed', 'Operation failed');
}
}
export function RequestErrorAlert({ request, title }: RequestErrorAlertProps) {
if (!request || !request.isError) {
return null;
}
const errorTitle = title || getDefaultTitle(request.endpointName);
const errorMessage = getMessageFromError(request.error);
return (
<Alert severity="error" title={errorTitle}>
{errorMessage}
</Alert>
);
}

@ -16,7 +16,8 @@ export interface SynchronizeStepProps {
export function SynchronizeStep({ onStepStatusUpdate, requiresMigration }: SynchronizeStepProps) { export function SynchronizeStep({ onStepStatusUpdate, requiresMigration }: SynchronizeStepProps) {
const [createJob] = useCreateRepositoryJobsMutation(); const [createJob] = useCreateRepositoryJobsMutation();
const { getValues, register } = useFormContext<WizardFormData>(); const { getValues, register, watch } = useFormContext<WizardFormData>();
const target = watch('repository.sync.target');
const [job, setJob] = useState<Job>(); const [job, setJob] = useState<Job>();
const startSynchronization = async () => { const startSynchronization = async () => {
@ -110,7 +111,7 @@ export function SynchronizeStep({ onStepStatusUpdate, requiresMigration }: Synch
</li> </li>
</ul> </ul>
</Alert> </Alert>
{requiresMigration && ( {requiresMigration && target !== 'folder' && (
<> <>
<Text element="h3"> <Text element="h3">
<Trans i18nKey="provisioning.synchronize-step.synchronization-options">Synchronization options</Trans> <Trans i18nKey="provisioning.synchronize-step.synchronization-options">Synchronization options</Trans>

@ -0,0 +1,39 @@
import { ErrorDetails } from 'app/api/clients/provisioning';
import { WizardFormData } from '../Wizard/types';
export type RepositoryField = keyof WizardFormData['repository'];
export type RepositoryFormPath = `repository.${RepositoryField}`;
export type FormErrorTuple = [RepositoryFormPath | null, { message: string } | null];
/**
* Maps API error details to form error fields for React Hook Form
*
* @param errors Array of error details from the API response
* @returns Tuple with form field path and error message
*/
export const getFormErrors = (errors: ErrorDetails[]): FormErrorTuple => {
const fieldsToValidate = ['local.path', 'github.branch', 'github.url', 'github.token'];
const fieldMap: Record<string, RepositoryFormPath> = {
path: 'repository.path',
branch: 'repository.branch',
url: 'repository.url',
token: 'repository.token',
};
for (const error of errors) {
if (error.field) {
const cleanField = error.field.replace('spec.', '');
if (fieldsToValidate.includes(cleanField)) {
const fieldParts = cleanField.split('.');
const lastPart = fieldParts[fieldParts.length - 1];
if (lastPart in fieldMap) {
return [fieldMap[lastPart], { message: error.detail || `Invalid ${lastPart}` }];
}
}
}
}
return [null, null];
};

@ -6633,7 +6633,6 @@
"title-legacy-storage-detected": "Legacy storage detected" "title-legacy-storage-detected": "Legacy storage detected"
}, },
"job-status": { "job-status": {
"error-unknown": "An unknown error occurred",
"label-view-details": "View details", "label-view-details": "View details",
"loading-finished-job": "Loading finished job...", "loading-finished-job": "Loading finished job...",
"no-job-found": "No job found", "no-job-found": "No job found",
@ -6731,12 +6730,6 @@
"title-legacy-storage": "Legacy Storage", "title-legacy-storage": "Legacy Storage",
"title-queued-for-deletion": "Queued for deletion" "title-queued-for-deletion": "Queued for deletion"
}, },
"request-error": {
"failed-to-migrate": "Failed to migrate dashboards",
"failed-to-save": "Failed to save repository",
"failed-to-sync": "Failed to sync dashboards",
"operation-failed": "Operation failed"
},
"resource-view": { "resource-view": {
"base": "Base", "base": "Base",
"dashboard-preview": "Dashboard Preview", "dashboard-preview": "Dashboard Preview",
@ -6799,8 +6792,7 @@
"button-cancel": "Cancel", "button-cancel": "Cancel",
"button-cancelling": "Cancelling...", "button-cancelling": "Cancelling...",
"button-submitting": "Submitting...", "button-submitting": "Submitting...",
"error-instance-repository-exists": "Instance repository already exists", "error-instance-repository-exists": "Instance repository already exists"
"title-repository-verification-failed": "Repository verification failed"
} }
}, },
"public-dashboard": { "public-dashboard": {

Loading…
Cancel
Save