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
Success bool `json:"success"`
// Error descriptions
Errors []string `json:"errors,omitempty"`
// Field related errors
Errors []ErrorDetails `json:"errors,omitempty"`
}
// Optional details
Details *common.Unstructured `json:"details,omitempty"`
type ErrorDetails struct {
Type metav1.CauseType `json:"type"`
Field string `json:"field,omitempty"`
Detail string `json:"detail,omitempty"`
}
// HistoryList is a list of versions of a resource

@ -27,6 +27,22 @@ func (in *Author) DeepCopy() *Author {
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.
func (in *ExportJobOptions) DeepCopyInto(out *ExportJobOptions) {
*out = *in
@ -849,13 +865,9 @@ func (in *TestResults) DeepCopyInto(out *TestResults) {
out.TypeMeta = in.TypeMeta
if in.Errors != nil {
in, out := &in.Errors, &out.Errors
*out = make([]string, len(*in))
*out = make([]ErrorDetails, len(*in))
copy(*out, *in)
}
if in.Details != nil {
in, out := &in.Details, &out.Details
*out = (*in).DeepCopy()
}
return
}

@ -15,6 +15,7 @@ import (
func GetOpenAPIDefinitions(ref common.ReferenceCallback) 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.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.FileItem": schema_pkg_apis_provisioning_v0alpha1_FileItem(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 {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@ -1909,31 +1942,24 @@ func schema_pkg_apis_provisioning_v0alpha1_TestResults(ref common.ReferenceCallb
},
"errors": {
SchemaProps: spec.SchemaProps{
Description: "Error descriptions",
Description: "Field related errors",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ErrorDetails"),
},
},
},
},
},
"details": {
SchemaProps: spec.SchemaProps{
Description: "Optional details",
Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"),
},
},
},
Required: []string{"code", "success"},
},
},
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 {
res = &provisioning.TestResults{
Success: false,
Errors: []string{
"error running test repository",
err.Error(),
},
Errors: []provisioning.ErrorDetails{{
Detail: fmt.Sprintf("error running test repository: %s", err.Error()),
}},
}
}
healthStatus := provisioning.HealthStatus{
Healthy: res.Success,
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)
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 repository.NewGitHub(ctx, r, b.ghFactory, b.secrets, webhookURL, cloneFn)
return repository.NewGitHub(ctx, r, b.ghFactory, b.secrets, webhookURL, cloneFn), nil
default:
return nil, fmt.Errorf("unknown repository type (%s)", r.Spec.Type)
}

@ -12,12 +12,11 @@ import (
"strings"
"github.com/google/go-github/v70/github"
"github.com/google/uuid"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
"github.com/google/uuid"
"github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
@ -57,18 +56,14 @@ func NewGitHub(
secrets secrets.Service,
webhookURL string,
cloneFn CloneFn,
) (*githubRepository, error) {
owner, repo, err := parseOwnerRepo(config.Spec.GitHub.URL)
if err != nil {
return nil, err
}
) *githubRepository {
owner, repo, _ := parseOwnerRepo(config.Spec.GitHub.URL)
token := config.Spec.GitHub.Token
if token == "" {
decrypted, err := secrets.Decrypt(ctx, config.Spec.GitHub.EncryptedToken)
if err != nil {
return nil, err
if err == nil {
token = string(decrypted)
}
token = string(decrypted)
}
return &githubRepository{
config: config,
@ -78,7 +73,7 @@ func NewGitHub(
owner: owner,
repo: repo,
cloneFn: cloneFn,
}, nil
}
}
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
}
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.
func (r *githubRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
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 {
return fromError(err, http.StatusBadRequest), nil
return fromFieldError(field.Invalid(
field.NewPath("spec", "github", "url"), url, err.Error())), nil
}
// FIXME: check token permissions
ok, err := r.gh.RepoExists(ctx, owner, repo)
if err != nil {
return fromError(err, http.StatusBadRequest), nil
return fromFieldError(field.Invalid(
field.NewPath("spec", "github", "url"), url, err.Error())), nil
}
if !ok {
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []string{"repository does not exist"},
}, nil
return fromFieldError(field.NotFound(
field.NewPath("spec", "github", "url"), url)), 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 {
return fromError(err, http.StatusBadRequest), nil
return fromFieldError(field.Invalid(
field.NewPath("spec", "github", "branch"), branch, err.Error())), nil
}
if !ok {
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []string{"branch does not exist"},
}, nil
return fromFieldError(field.NotFound(
field.NewPath("spec", "github", "branch"), branch)), nil
}
return &provisioning.TestResults{

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

@ -6,6 +6,7 @@ import (
"net/http"
"slices"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
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{
Code: http.StatusUnprocessableEntity, // Invalid
Success: false,
Errors: make([]string, len(errors)),
Errors: make([]provisioning.ErrorDetails, len(errors)),
}
for i, v := range errors {
rsp.Errors[i] = v.Error()
for i, err := range errors {
rsp.Errors[i] = provisioning.ErrorDetails{
Type: metav1.CauseType(err.Type),
Field: err.Field,
Detail: err.Detail,
}
}
return rsp, nil
}
@ -85,3 +90,15 @@ func ValidateRepository(repo Repository) field.ErrorList {
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"
"testing"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
)
func TestValidateRepository(t *testing.T) {
@ -192,7 +193,7 @@ func TestTestRepository(t *testing.T) {
name string
repository *MockRepository
expectedCode int
expectedErrs []string
expectedErrs []provisioning.ErrorDetails
expectedError error
}{
{
@ -208,7 +209,11 @@ func TestTestRepository(t *testing.T) {
return m
}(),
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",
@ -257,12 +262,18 @@ func TestTestRepository(t *testing.T) {
m.On("Test", mock.Anything).Return(&provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []string{"test failed"},
Errors: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueInvalid,
Field: "spec.property",
}},
}, nil)
return m
}(),
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 {
res = &provisioning.TestResults{
Success: false,
Errors: []string{
"missing health status",
},
Errors: []provisioning.ErrorDetails{{
Detail: "missing health status",
}},
}
}
@ -109,7 +109,11 @@ func (t *RepositoryTester) UpdateHealthStatus(ctx context.Context, cfg *provisio
repo.Status.Health = provisioning.HealthStatus{
Healthy: res.Success,
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()).

@ -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": {
"type": "object",
"properties": {
@ -3762,20 +3780,16 @@
"format": "int32",
"default": 0
},
"details": {
"description": "Optional details",
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apimachinery.apis.common.v0alpha1.Unstructured"
}
]
},
"errors": {
"description": "Error descriptions",
"description": "Field related errors",
"type": "array",
"items": {
"type": "string",
"default": ""
"default": {},
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ErrorDetails"
}
]
}
},
"kind": {

@ -1096,15 +1096,18 @@ export type ResourceList = {
kind?: string;
metadata?: ListMeta;
};
export type ErrorDetails = {
detail?: string;
field?: string;
type: string;
};
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?: string;
/** HTTP status code */
code: number;
/** Optional details */
details?: Unstructured;
/** Error descriptions */
errors?: string[];
/** Field related errors */
errors?: ErrorDetails[];
/** 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;
/** Is the connection healthy */

@ -13,6 +13,7 @@ import {
JobList,
Repository,
RepositoryList,
ErrorDetails,
} from './endpoints.gen';
import { createOnCacheEntryAdded } from './utils/createOnCacheEntryAdded';
@ -100,9 +101,11 @@ export const provisioningAPI = generatedAPI.enhanceEndpoints({
dispatch(notifyApp(createErrorNotification('Error validating repository', e)));
} else if (typeof e === 'object' && 'error' in e && isFetchError(e.error)) {
if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) {
dispatch(
notifyApp(createErrorNotification('Error validating repository', e.error.data.errors.join('\n')))
);
const nonFieldErrors = e.error.data.errors.filter((err: ErrorDetails) => !err.field);
// 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 { ActiveJobStatus } from './ActiveJobStatus';
import { FinishedJobStatus } from './FinishedJobStatus';
import { JobContent } from './JobContent';
export interface JobStatusProps {
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) {
return <ActiveJobStatus job={activeJob} />;
return <JobContent job={activeJob} isFinishedJob={false} />;
}
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 { 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 { useDeleteRepositoryMutation, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning';
import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt';
@ -14,11 +14,11 @@ import { getDefaultValues } from '../Config/defaults';
import { PROVISIONING_URL } from '../constants';
import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository';
import { dataToSpec } from '../utils/data';
import { getFormErrors } from '../utils/getFormErrors';
import { BootstrapStep } from './BootstrapStep';
import { ConnectStep } from './ConnectStep';
import { FinishStep } from './FinishStep';
import { RequestErrorAlert } from './RequestErrorAlert';
import { Step, Stepper } from './Stepper';
import { SynchronizeStep } from './SynchronizeStep';
import { RepoType, StepStatusInfo, WizardFormData, WizardStep } from './types';
@ -83,11 +83,12 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
setValue,
getValues,
trigger,
setError,
formState: { isDirty },
} = methods;
const repoName = watch('repositoryName');
const [submitData, saveRequest] = useCreateOrUpdateRepository(repoName);
const [submitData] = useCreateOrUpdateRepository(repoName);
const [deleteRepository] = useDeleteRepositoryMutation();
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);
}
} catch (error) {
setStepStatusInfo({
status: 'error',
error: 'Repository connection failed',
});
if (isFetchError(error)) {
const [field, errorMessage] = getFormErrors(error.data.errors);
if (field && errorMessage) {
setError(field, errorMessage);
}
} else {
setStepStatusInfo({
status: 'error',
error: 'Repository connection failed',
});
}
} finally {
setIsSubmitting(false);
}
@ -210,7 +218,12 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
if (activeStep === 'synchronize') {
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 (
@ -228,13 +241,9 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
</Text>
</Box>
<RequestErrorAlert
request={saveRequest}
title={t(
'provisioning.wizard-content.title-repository-verification-failed',
'Repository verification failed'
)}
/>
{stepStatusInfo.status === 'error' && (
<Alert severity="error" title={'error' in stepStatusInfo ? stepStatusInfo.error : ''} />
)}
<div className={styles.content}>
{activeStep === 'connection' && <ConnectStep />}
@ -252,16 +261,8 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
{activeStep === 'finish' && <FinishStep />}
</div>
{stepStatusInfo.status === 'error' && (
<Alert severity="error" title={'error' in stepStatusInfo ? stepStatusInfo.error : ''} />
)}
<Stack gap={2} justifyContent="flex-end">
<Button
variant={stepStatusInfo.status === 'error' ? 'primary' : 'secondary'}
onClick={handleCancel}
disabled={isSubmitting || isCancelling}
>
<Button variant={'secondary'} onClick={handleCancel} disabled={isSubmitting || isCancelling}>
{isCancelling
? t('provisioning.wizard-content.button-cancelling', 'Cancelling...')
: 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) {
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 startSynchronization = async () => {
@ -110,7 +111,7 @@ export function SynchronizeStep({ onStepStatusUpdate, requiresMigration }: Synch
</li>
</ul>
</Alert>
{requiresMigration && (
{requiresMigration && target !== 'folder' && (
<>
<Text element="h3">
<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"
},
"job-status": {
"error-unknown": "An unknown error occurred",
"label-view-details": "View details",
"loading-finished-job": "Loading finished job...",
"no-job-found": "No job found",
@ -6731,12 +6730,6 @@
"title-legacy-storage": "Legacy Storage",
"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": {
"base": "Base",
"dashboard-preview": "Dashboard Preview",
@ -6799,8 +6792,7 @@
"button-cancel": "Cancel",
"button-cancelling": "Cancelling...",
"button-submitting": "Submitting...",
"error-instance-repository-exists": "Instance repository already exists",
"title-repository-verification-failed": "Repository verification failed"
"error-instance-repository-exists": "Instance repository already exists"
}
},
"public-dashboard": {

Loading…
Cancel
Save