Provisioning: Use repository view rather than raw config (#103449)

pull/103293/head^2
Ryan McKinley 3 months ago committed by GitHub
parent 8cd6f837a5
commit 8dbaeac9da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 9
      .betterer.results
  2. 6
      pkg/apis/provisioning/v0alpha1/settings.go
  3. 9
      pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go
  4. 26
      pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go
  5. 1
      pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list
  6. 10
      pkg/registry/apis/provisioning/routes.go
  7. 21
      pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json
  8. 4
      public/app/api/clients/provisioning/endpoints.gen.ts
  9. 79
      public/app/features/browse-dashboards/components/NewProvisionedFolderForm.test.tsx
  10. 66
      public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx
  11. 4
      public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboard.tsx
  12. 24
      public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.test.tsx
  13. 10
      public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx
  14. 13
      public/app/features/dashboard-scene/saving/provisioned/defaults.ts
  15. 52
      public/app/features/dashboard-scene/saving/provisioned/hooks.ts
  16. 2
      public/app/features/provisioning/Config/ConfigForm.tsx
  17. 2
      public/app/features/provisioning/HomePage.tsx
  18. 2
      public/app/features/provisioning/Wizard/WizardContent.tsx
  19. 7
      public/app/features/provisioning/hooks/index.ts
  20. 25
      public/app/features/provisioning/hooks/useGetResourceRepository.ts
  21. 82
      public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts
  22. 14
      public/app/features/provisioning/hooks/useIsProvisionedNG.ts

@ -3098,15 +3098,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"] [0, 0, 0, "Do not use any type assertions.", "1"]
], ],
"public/app/features/provisioning/hooks/index.ts:5381": [
[0, 0, 0, "Do not re-export imported variable (\`./useCreateOrUpdateRepositoryFile\`)", "0"],
[0, 0, 0, "Do not re-export imported variable (\`./useCreateOrUpdateRepository\`)", "1"],
[0, 0, 0, "Do not re-export imported variable (\`./useGetResourceRepository\`)", "2"],
[0, 0, 0, "Do not re-export imported variable (\`./useIsProvisionedNG\`)", "3"],
[0, 0, 0, "Do not re-export imported variable (\`./usePullRequestParam\`)", "4"],
[0, 0, 0, "Do not re-export imported variable (\`./useRepositoryJobs\`)", "5"],
[0, 0, 0, "Do not re-export imported variable (\`./useRepositoryList\`)", "6"]
],
"public/app/features/provisioning/types.ts:5381": [ "public/app/features/provisioning/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"] [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
], ],

@ -25,12 +25,12 @@ type RepositoryView struct {
// Repository display // Repository display
Title string `json:"title"` Title string `json:"title"`
// Edit options within the repository
ReadOnly bool `json:"readOnly"`
// The repository type // The repository type
Type RepositoryType `json:"type"` Type RepositoryType `json:"type"`
// When syncing, where values are saved // When syncing, where values are saved
Target SyncTargetType `json:"target"` Target SyncTargetType `json:"target"`
// The supported workflows
Workflows []Workflow `json:"workflows"`
} }

@ -590,6 +590,11 @@ func (in *RepositoryStatus) DeepCopy() *RepositoryStatus {
// 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 *RepositoryView) DeepCopyInto(out *RepositoryView) { func (in *RepositoryView) DeepCopyInto(out *RepositoryView) {
*out = *in *out = *in
if in.Workflows != nil {
in, out := &in.Workflows, &out.Workflows
*out = make([]Workflow, len(*in))
copy(*out, *in)
}
return return
} }
@ -610,7 +615,9 @@ func (in *RepositoryViewList) DeepCopyInto(out *RepositoryViewList) {
if in.Items != nil { if in.Items != nil {
in, out := &in.Items, &out.Items in, out := &in.Items, &out.Items
*out = make([]RepositoryView, len(*in)) *out = make([]RepositoryView, len(*in))
copy(*out, *in) for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
} }
return return
} }

@ -1212,14 +1212,6 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositoryView(ref common.ReferenceCa
Format: "", Format: "",
}, },
}, },
"readOnly": {
SchemaProps: spec.SchemaProps{
Description: "Edit options within the repository",
Default: false,
Type: []string{"boolean"},
Format: "",
},
},
"type": { "type": {
SchemaProps: spec.SchemaProps{ SchemaProps: spec.SchemaProps{
Description: "The repository type\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`", Description: "The repository type\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`",
@ -1238,8 +1230,24 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositoryView(ref common.ReferenceCa
Enum: []interface{}{"folder", "instance"}, Enum: []interface{}{"folder", "instance"},
}, },
}, },
"workflows": {
SchemaProps: spec.SchemaProps{
Description: "The supported workflows",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
Enum: []interface{}{"branch", "write"},
},
},
},
},
},
}, },
Required: []string{"name", "title", "readOnly", "type", "target"}, Required: []string{"name", "title", "type", "target", "workflows"},
}, },
}, },
} }

@ -6,6 +6,7 @@ API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provis
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,ManagerStats,Stats API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,ManagerStats,Stats
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,RepositoryList,Items API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,RepositoryList,Items
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,RepositorySpec,Workflows API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,RepositorySpec,Workflows
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,RepositoryView,Workflows
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,RepositoryViewList,Items API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,RepositoryViewList,Items
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,ResourceList,Items API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,ResourceList,Items
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,TestResults,Errors API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,TestResults,Errors

@ -163,11 +163,11 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) {
} }
for i, val := range all { for i, val := range all {
settings.Items[i] = provisioning.RepositoryView{ settings.Items[i] = provisioning.RepositoryView{
Name: val.ObjectMeta.Name, Name: val.ObjectMeta.Name,
Title: val.Spec.Title, Title: val.Spec.Title,
Type: val.Spec.Type, Type: val.Spec.Type,
ReadOnly: len(val.Spec.Workflows) == 0, Target: val.Spec.Sync.Target,
Target: val.Spec.Sync.Target, Workflows: val.Spec.Workflows,
} }
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

@ -3229,9 +3229,9 @@
"required": [ "required": [
"name", "name",
"title", "title",
"readOnly",
"type", "type",
"target" "target",
"workflows"
], ],
"properties": { "properties": {
"name": { "name": {
@ -3239,11 +3239,6 @@
"type": "string", "type": "string",
"default": "" "default": ""
}, },
"readOnly": {
"description": "Edit options within the repository",
"type": "boolean",
"default": false
},
"target": { "target": {
"description": "When syncing, where values are saved\n\nPossible enum values:\n - `\"folder\"` Resources will be saved into a folder managed by this repository It will contain a copy of everything from the remote The folder k8s name will be the same as the repository k8s name\n - `\"instance\"` Resources are saved in the global context Only one repository may specify the `instance` target When this exists, the UI will promote writing to the instance repo rather than the grafana database (where possible)", "description": "When syncing, where values are saved\n\nPossible enum values:\n - `\"folder\"` Resources will be saved into a folder managed by this repository It will contain a copy of everything from the remote The folder k8s name will be the same as the repository k8s name\n - `\"instance\"` Resources are saved in the global context Only one repository may specify the `instance` target When this exists, the UI will promote writing to the instance repo rather than the grafana database (where possible)",
"type": "string", "type": "string",
@ -3266,6 +3261,18 @@
"github", "github",
"local" "local"
] ]
},
"workflows": {
"description": "The supported workflows",
"type": "array",
"items": {
"type": "string",
"default": "",
"enum": [
"branch",
"write"
]
}
} }
} }
}, },

@ -1124,8 +1124,6 @@ export type WebhookResponse = {
export type RepositoryView = { export type RepositoryView = {
/** The k8s name for this repository */ /** The k8s name for this repository */
name: string; name: string;
/** Edit options within the repository */
readOnly: boolean;
/** When syncing, where values are saved /** When syncing, where values are saved
Possible enum values: Possible enum values:
@ -1140,6 +1138,8 @@ export type RepositoryView = {
- `"github"` - `"github"`
- `"local"` */ - `"local"` */
type: 'github' | 'local'; type: 'github' | 'local';
/** The supported workflows */
workflows: ('branch' | 'write')[];
}; };
export type RepositoryViewList = { export type RepositoryViewList = {
/** 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 */

@ -6,7 +6,8 @@ import { getAppEvents } from '@grafana/runtime';
import { useGetFolderQuery } from 'app/api/clients/folder'; import { useGetFolderQuery } from 'app/api/clients/folder';
import { useCreateRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning'; import { useCreateRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning';
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv'; import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
import { usePullRequestParam, useRepositoryList } from 'app/features/provisioning/hooks'; import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView';
import { usePullRequestParam } from 'app/features/provisioning/hooks/usePullRequestParam';
import { FolderDTO } from '../../../types'; import { FolderDTO } from '../../../types';
@ -46,10 +47,15 @@ jest.mock('app/api/clients/folder', () => {
}; };
}); });
jest.mock('app/features/provisioning/hooks', () => { jest.mock('app/features/provisioning/hooks/usePullRequestParam', () => {
return { return {
usePullRequestParam: jest.fn(), usePullRequestParam: jest.fn(),
useRepositoryList: jest.fn(), };
});
jest.mock('app/features/provisioning/hooks/useGetResourceRepositoryView', () => {
return {
useGetResourceRepositoryView: jest.fn(),
}; };
}); });
@ -127,25 +133,19 @@ describe('NewProvisionedFolderForm', () => {
}; };
(getAppEvents as jest.Mock).mockReturnValue(mockAppEvents); (getAppEvents as jest.Mock).mockReturnValue(mockAppEvents);
(useRepositoryList as jest.Mock).mockReturnValue([ (useGetResourceRepositoryView as jest.Mock).mockReturnValue({
[ isLoading: false,
{ repository: {
metadata: { name: 'test-repo',
name: 'test-repo', title: 'Test Repository',
}, type: 'github',
spec: { github: {
title: 'Test Repository', url: 'https://github.com/grafana/grafana',
type: 'github', branch: 'main',
github: {
url: 'https://github.com/grafana/grafana',
branch: 'main',
},
workflows: [{ name: 'default', path: 'workflows/default.yaml' }],
},
}, },
], workflows: [{ name: 'default', path: 'workflows/default.json' }],
false, },
]); });
// Mock useGetFolderQuery // Mock useGetFolderQuery
(useGetFolderQuery as jest.Mock).mockReturnValue({ (useGetFolderQuery as jest.Mock).mockReturnValue({
@ -183,7 +183,9 @@ describe('NewProvisionedFolderForm', () => {
}); });
it('should show loading state when repository data is loading', () => { it('should show loading state when repository data is loading', () => {
(useRepositoryList as jest.Mock).mockReturnValue([[], true]); (useGetResourceRepositoryView as jest.Mock).mockReturnValue({
isLoading: true,
});
setup(); setup();
@ -191,7 +193,10 @@ describe('NewProvisionedFolderForm', () => {
}); });
it('should show error when repository is not found', () => { it('should show error when repository is not found', () => {
(useRepositoryList as jest.Mock).mockReturnValue([null, false]); (useGetResourceRepositoryView as jest.Mock).mockReturnValue({
isLoading: false,
repository: undefined,
});
setup(); setup();
@ -423,24 +428,18 @@ describe('NewProvisionedFolderForm', () => {
it('should show read-only alert when repository has no workflows', () => { it('should show read-only alert when repository has no workflows', () => {
// Mock repository with empty workflows array // Mock repository with empty workflows array
(useRepositoryList as jest.Mock).mockReturnValue([ (useGetResourceRepositoryView as jest.Mock).mockReturnValue({
[ repository: {
{ name: 'test-repo',
metadata: { title: 'Test Repository',
name: 'test-repo', type: 'github',
}, github: {
spec: { url: 'https://github.com/grafana/grafana',
type: 'github', branch: 'main',
github: {
url: 'https://github.com/grafana/grafana',
branch: 'main',
},
workflows: [], // Empty workflows array
},
}, },
], workflows: [],
false, },
]); });
setup(); setup();

@ -1,4 +1,3 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom-v5-compat'; import { useNavigate } from 'react-router-dom-v5-compat';
@ -6,15 +5,15 @@ import { useNavigate } from 'react-router-dom-v5-compat';
import { AppEvents } from '@grafana/data'; import { AppEvents } from '@grafana/data';
import { getAppEvents } from '@grafana/runtime'; import { getAppEvents } from '@grafana/runtime';
import { Alert, Button, Field, Input, RadioButtonGroup, Spinner, Stack, TextArea } from '@grafana/ui'; import { Alert, Button, Field, Input, RadioButtonGroup, Spinner, Stack, TextArea } from '@grafana/ui';
import { useGetFolderQuery } from 'app/api/clients/folder';
import { useCreateRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning'; import { useCreateRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning';
import { t, Trans } from 'app/core/internationalization'; import { t, Trans } from 'app/core/internationalization';
import { AnnoKeyManagerIdentity, AnnoKeySourcePath, Resource } from 'app/features/apiserver/types'; import { AnnoKeySourcePath, Resource } from 'app/features/apiserver/types';
import { getDefaultWorkflow, getWorkflowOptions } from 'app/features/dashboard-scene/saving/provisioned/defaults'; import { getDefaultWorkflow, getWorkflowOptions } from 'app/features/dashboard-scene/saving/provisioned/defaults';
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv'; import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
import { BranchValidationError } from 'app/features/provisioning/Shared/BranchValidationError'; import { BranchValidationError } from 'app/features/provisioning/Shared/BranchValidationError';
import { PROVISIONING_URL } from 'app/features/provisioning/constants'; import { PROVISIONING_URL } from 'app/features/provisioning/constants';
import { usePullRequestParam, useRepositoryList } from 'app/features/provisioning/hooks'; import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView';
import { usePullRequestParam } from 'app/features/provisioning/hooks/usePullRequestParam';
import { WorkflowOption } from 'app/features/provisioning/types'; import { WorkflowOption } from 'app/features/provisioning/types';
import { validateBranchName } from 'app/features/provisioning/utils/git'; import { validateBranchName } from 'app/features/provisioning/utils/git';
import { FolderDTO } from 'app/types'; import { FolderDTO } from 'app/types';
@ -41,26 +40,12 @@ const initialFormValues: Partial<FormData> = {
}; };
export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: Props) { export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: Props) {
const [items, isLoading] = useRepositoryList(); const { repository, folder, isLoading } = useGetResourceRepositoryView({ folderName: parentFolder?.uid });
const prURL = usePullRequestParam(); const prURL = usePullRequestParam();
const navigate = useNavigate(); const navigate = useNavigate();
const [create, request] = useCreateRepositoryFilesWithPathMutation(); const [create, request] = useCreateRepositoryFilesWithPathMutation();
// Get k8s folder data, necessary to get parent folder path const isGitHub = Boolean(repository?.type === 'github');
const folderQuery = useGetFolderQuery(parentFolder ? { name: parentFolder.uid } : skipToken);
const repositoryName = folderQuery.data?.metadata?.annotations?.[AnnoKeyManagerIdentity];
if (!items && !isLoading) {
return (
<Alert
title={t('browse-dashboards.new-provisioned-folder-form.title-repository-not-found', 'Repository not found')}
severity="error"
/>
);
}
const repository = repositoryName ? items?.find((item) => item?.metadata?.name === repositoryName) : items?.[0];
const repositoryConfig = repository?.spec;
const isGitHub = Boolean(repositoryConfig?.github);
const { const {
register, register,
@ -69,17 +54,17 @@ export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: P
formState: { errors }, formState: { errors },
control, control,
setValue, setValue,
} = useForm<FormData>({ defaultValues: { ...initialFormValues, workflow: getDefaultWorkflow(repositoryConfig) } }); } = useForm<FormData>({ defaultValues: { ...initialFormValues, workflow: getDefaultWorkflow(repository) } });
const [workflow, ref] = watch(['workflow', 'ref']); const [workflow, ref] = watch(['workflow', 'ref']);
useEffect(() => { useEffect(() => {
setValue('workflow', getDefaultWorkflow(repositoryConfig)); setValue('workflow', getDefaultWorkflow(repository));
}, [repositoryConfig, setValue]); }, [repository, setValue]);
useEffect(() => { useEffect(() => {
const appEvents = getAppEvents(); const appEvents = getAppEvents();
if (request.isSuccess) { if (request.isSuccess && repository) {
onSubmit(); onSubmit();
appEvents.publish({ appEvents.publish({
@ -100,7 +85,7 @@ export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: P
return; return;
} }
let url = `${PROVISIONING_URL}/${repositoryName}/file/${request.data.path}`; let url = `${PROVISIONING_URL}/${repository.name}/file/${request.data.path}`;
if (request.data.ref?.length) { if (request.data.ref?.length) {
url += '?ref=' + request.data.ref; url += '?ref=' + request.data.ref;
} }
@ -114,22 +99,21 @@ export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: P
], ],
}); });
} }
}, [ }, [request.isSuccess, request.isError, request.error, onSubmit, ref, request.data, workflow, navigate, repository]);
request.isSuccess,
request.isError,
request.error,
onSubmit,
ref,
request.data,
workflow,
navigate,
repositoryName,
]);
if (isLoading || folderQuery.isLoading) { if (isLoading) {
return <Spinner />; return <Spinner />;
} }
if (!repository) {
return (
<Alert
title={t('browse-dashboards.new-provisioned-folder-form.title-repository-not-found', 'Repository not found')}
severity="error"
/>
);
}
const validateFolderName = async (folderName: string) => { const validateFolderName = async (folderName: string) => {
try { try {
await validationSrv.validateNewFolderName(folderName); await validationSrv.validateNewFolderName(folderName);
@ -143,11 +127,11 @@ export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: P
}; };
const doSave = async ({ ref, title, workflow, comment }: FormData) => { const doSave = async ({ ref, title, workflow, comment }: FormData) => {
const repoName = repository?.metadata?.name; const repoName = repository?.name;
if (!title || !repoName) { if (!title || !repoName) {
return; return;
} }
const basePath = folderQuery.data?.metadata?.annotations?.[AnnoKeySourcePath] ?? ''; const basePath = folder?.metadata?.annotations?.[AnnoKeySourcePath] ?? '';
// Convert folder title to filename format (lowercase, replace spaces with hyphens) // Convert folder title to filename format (lowercase, replace spaces with hyphens)
const titleInFilenameFormat = title const titleInFilenameFormat = title
@ -179,7 +163,7 @@ export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: P
return ( return (
<form onSubmit={handleSubmit(doSave)}> <form onSubmit={handleSubmit(doSave)}>
<Stack direction="column" gap={2}> <Stack direction="column" gap={2}>
{!repositoryConfig?.workflows.length && ( {!repository?.workflows?.length && (
<Alert <Alert
title={t( title={t(
'browse-dashboards.new-provisioned-folder-form.title-this-repository-is-read-only', 'browse-dashboards.new-provisioned-folder-form.title-this-repository-is-read-only',
@ -229,7 +213,7 @@ export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: P
control={control} control={control}
name="workflow" name="workflow"
render={({ field: { ref, ...field } }) => ( render={({ field: { ref, ...field } }) => (
<RadioButtonGroup {...field} options={getWorkflowOptions(repositoryConfig)} id={'folder-workflow'} /> <RadioButtonGroup {...field} options={getWorkflowOptions(repository)} id={'folder-workflow'} />
)} )}
/> />
</Field> </Field>

@ -24,7 +24,7 @@ export function SaveProvisionedDashboard({ drawer, changeInfo, dashboard }: Save
if (!defaultValues) { if (!defaultValues) {
return null; return null;
} }
const { values, isNew, isGitHub, repositoryConfig } = defaultValues; const { values, isNew, isGitHub, repository } = defaultValues;
return ( return (
<SaveProvisionedDashboardForm <SaveProvisionedDashboardForm
@ -35,7 +35,7 @@ export function SaveProvisionedDashboard({ drawer, changeInfo, dashboard }: Save
defaultValues={values} defaultValues={values}
loadedFromRef={loadedFromRef} loadedFromRef={loadedFromRef}
isGitHub={isGitHub} isGitHub={isGitHub}
repositoryConfig={repositoryConfig} repository={repository}
/> />
); );
} }

@ -48,15 +48,9 @@ jest.mock('app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile', ()
}; };
}); });
jest.mock('app/features/provisioning/hooks/useGetResourceRepository', () => { jest.mock('app/features/provisioning/hooks/useGetResourceRepositoryView', () => {
return { return {
useGetResourceRepository: jest.fn(), useGetResourceRepositoryView: jest.fn(),
};
});
jest.mock('app/features/provisioning/hooks/useRepositoryList', () => {
return {
useRepositoryList: jest.fn(),
}; };
}); });
@ -133,15 +127,12 @@ function setup(props: Partial<Props> = {}) {
description: 'Test Description', description: 'Test Description',
workflow: 'write', workflow: 'write',
}, },
repositoryConfig: { repository: {
name: 'repo-xyz',
type: 'github', type: 'github',
workflows: ['write', 'branch'], workflows: ['write', 'branch'],
sync: { enabled: false, target: 'folder' },
title: 'Test Repository', title: 'Test Repository',
github: { target: 'folder',
branch: 'main',
generateDashboardPreviews: false,
},
}, },
...props, ...props,
}; };
@ -401,10 +392,11 @@ describe('SaveProvisionedDashboardForm', () => {
it('should show read-only alert when repository has no workflows', () => { it('should show read-only alert when repository has no workflows', () => {
setup({ setup({
repositoryConfig: { repository: {
name: 'repo-abc',
type: 'github', type: 'github',
workflows: [], workflows: [],
sync: { enabled: false, target: 'folder' }, target: 'folder',
title: 'Read-only Repository', title: 'Read-only Repository',
}, },
}); });

@ -6,7 +6,7 @@ import { AppEvents, locationUtil } from '@grafana/data';
import { getAppEvents, locationService } from '@grafana/runtime'; import { getAppEvents, locationService } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema'; import { Dashboard } from '@grafana/schema';
import { Alert, Button, Field, Input, RadioButtonGroup, Stack, TextArea } from '@grafana/ui'; import { Alert, Button, Field, Input, RadioButtonGroup, Stack, TextArea } from '@grafana/ui';
import { RepositorySpec } from 'app/api/clients/provisioning'; import { RepositoryView } from 'app/api/clients/provisioning';
import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { t, Trans } from 'app/core/internationalization'; import { t, Trans } from 'app/core/internationalization';
import kbn from 'app/core/utils/kbn'; import kbn from 'app/core/utils/kbn';
@ -43,7 +43,7 @@ export interface Props extends SaveProvisionedDashboardProps {
isNew: boolean; isNew: boolean;
defaultValues: FormData; defaultValues: FormData;
isGitHub: boolean; isGitHub: boolean;
repositoryConfig?: RepositorySpec; repository?: RepositoryView;
loadedFromRef?: string; loadedFromRef?: string;
} }
@ -54,7 +54,7 @@ export function SaveProvisionedDashboardForm({
changeInfo, changeInfo,
isNew, isNew,
loadedFromRef, loadedFromRef,
repositoryConfig, repository,
isGitHub, isGitHub,
}: Props) { }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
@ -154,12 +154,12 @@ export function SaveProvisionedDashboardForm({
}); });
}; };
const workflowOptions = getWorkflowOptions(repositoryConfig, loadedFromRef); const workflowOptions = getWorkflowOptions(repository, loadedFromRef);
return ( return (
<form onSubmit={handleSubmit(handleFormSubmit)} name="save-provisioned-form"> <form onSubmit={handleSubmit(handleFormSubmit)} name="save-provisioned-form">
<Stack direction="column" gap={2}> <Stack direction="column" gap={2}>
{!repositoryConfig?.workflows.length && ( {!repository?.workflows?.length && (
<Alert <Alert
title={t( title={t(
'dashboard-scene.save-provisioned-dashboard-form.title-this-repository-is-read-only', 'dashboard-scene.save-provisioned-dashboard-form.title-this-repository-is-read-only',

@ -1,22 +1,21 @@
import { RepositorySpec } from 'app/api/clients/provisioning'; import { RepositoryView } from 'app/api/clients/provisioning';
import { WorkflowOption } from 'app/features/provisioning/types'; import { WorkflowOption } from 'app/features/provisioning/types';
export function getDefaultWorkflow(config?: RepositorySpec) { export function getDefaultWorkflow(config?: RepositoryView) {
return config?.workflows?.[0]; return config?.workflows?.[0];
} }
export function getWorkflowOptions(config?: RepositorySpec, ref?: string) { export function getWorkflowOptions(config?: RepositoryView, ref?: string) {
if (!config) { if (!config) {
return []; return [];
} }
if (config.local?.path) { if (config.type === 'local') {
return [{ label: `Write to ${config.local.path}`, value: 'write' }]; return [{ label: `Save`, value: 'write' }];
} }
let branch = ref ?? config.github?.branch;
const availableOptions: Array<{ label: string; value: WorkflowOption }> = [ const availableOptions: Array<{ label: string; value: WorkflowOption }> = [
{ label: `Push to ${branch ?? 'main'}`, value: 'write' }, { label: ref ? `Push to ${ref}` : 'Save', value: 'write' },
{ label: 'Push to different branch', value: 'branch' }, { label: 'Push to different branch', value: 'branch' },
]; ];

@ -1,9 +1,5 @@
import { skipToken } from '@reduxjs/toolkit/query/react';
import { useGetFolderQuery } from 'app/api/clients/folder';
import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, AnnoKeySourcePath } from 'app/features/apiserver/types'; import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, AnnoKeySourcePath } from 'app/features/apiserver/types';
import { useGetResourceRepository } from 'app/features/provisioning/hooks/useGetResourceRepository'; import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView';
import { useRepositoryList } from 'app/features/provisioning/hooks/useRepositoryList';
import { DashboardMeta } from 'app/types'; import { DashboardMeta } from 'app/types';
import { getDefaultWorkflow } from './defaults'; import { getDefaultWorkflow } from './defaults';
@ -21,14 +17,13 @@ export function useDefaultValues({ meta, defaultTitle, defaultDescription }: Use
const managerKind = annotations?.[AnnoKeyManagerKind]; const managerKind = annotations?.[AnnoKeyManagerKind];
const managerIdentity = annotations?.[AnnoKeyManagerIdentity]; const managerIdentity = annotations?.[AnnoKeyManagerIdentity];
const sourcePath = annotations?.[AnnoKeySourcePath]; const sourcePath = annotations?.[AnnoKeySourcePath];
const repositoryConfig = useConfig({ folderUid: meta.folderUid, managerKind, managerIdentity }); const { repository, folder, isLoading } = useGetResourceRepositoryView({
const repository = repositoryConfig?.spec; name: managerKind === 'repo' ? managerIdentity : undefined,
folderName: meta.folderUid,
});
const timestamp = generateTimestamp(); const timestamp = generateTimestamp();
// Get folder data to retrieve the folder path const folderPath = folder?.metadata?.annotations?.[AnnoKeySourcePath];
const folderQuery = useGetFolderQuery(meta.folderUid ? { name: meta.folderUid } : skipToken);
const folderPath = meta.folderUid ? (folderQuery.data?.metadata?.annotations?.[AnnoKeySourcePath] ?? '') : '';
const dashboardPath = generatePath({ const dashboardPath = generatePath({
timestamp, timestamp,
@ -37,7 +32,7 @@ export function useDefaultValues({ meta, defaultTitle, defaultDescription }: Use
folderPath, folderPath,
}); });
if (folderQuery.isLoading || !repositoryConfig) { if (isLoading || !repository) {
return null; return null;
} }
@ -45,7 +40,7 @@ export function useDefaultValues({ meta, defaultTitle, defaultDescription }: Use
values: { values: {
ref: `dashboard/${timestamp}`, ref: `dashboard/${timestamp}`,
path: dashboardPath, path: dashboardPath,
repo: managerIdentity || repositoryConfig?.metadata?.name || '', repo: managerIdentity || repository?.name || '',
comment: '', comment: '',
folder: { folder: {
uid: meta.folderUid, uid: meta.folderUid,
@ -56,36 +51,7 @@ export function useDefaultValues({ meta, defaultTitle, defaultDescription }: Use
workflow: getDefaultWorkflow(repository), workflow: getDefaultWorkflow(repository),
}, },
isNew: !meta.k8s?.name, isNew: !meta.k8s?.name,
repositoryConfig: repository,
isGitHub: repository?.type === 'github', isGitHub: repository?.type === 'github',
repository,
}; };
} }
type UseConfigArgs = {
folderUid?: string;
managerKind?: string;
managerIdentity?: string;
};
const useConfig = ({ folderUid, managerKind, managerIdentity }: UseConfigArgs) => {
const repositoryConfig = useGetResourceRepository({
name: managerKind === 'repo' ? managerIdentity : undefined,
folderUid,
});
const [items, isLoading] = useRepositoryList(repositoryConfig ? skipToken : undefined);
if (repositoryConfig) {
return repositoryConfig;
}
if (isLoading) {
return null;
}
const instanceConfig = items?.find((repo) => repo.spec?.sync.target === 'instance');
if (instanceConfig) {
return instanceConfig;
}
// Return the config, which targets the folder
return items?.find((repo) => repo?.metadata?.name === folderUid);
};

@ -20,7 +20,7 @@ import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt';
import { t } from 'app/core/internationalization'; import { t } from 'app/core/internationalization';
import { TokenPermissionsInfo } from '../Shared/TokenPermissionsInfo'; import { TokenPermissionsInfo } from '../Shared/TokenPermissionsInfo';
import { useCreateOrUpdateRepository } from '../hooks'; import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository';
import { RepositoryFormData, WorkflowOption } from '../types'; import { RepositoryFormData, WorkflowOption } from '../types';
import { dataToSpec, specToData } from '../utils/data'; import { dataToSpec, specToData } from '../utils/data';

@ -8,7 +8,7 @@ import { t, Trans } from 'app/core/internationalization';
import GettingStarted from './GettingStarted/GettingStarted'; import GettingStarted from './GettingStarted/GettingStarted';
import GettingStartedPage from './GettingStarted/GettingStartedPage'; import GettingStartedPage from './GettingStarted/GettingStartedPage';
import { FolderRepositoryList } from './Shared/FolderRepositoryList'; import { FolderRepositoryList } from './Shared/FolderRepositoryList';
import { useRepositoryList } from './hooks'; import { useRepositoryList } from './hooks/useRepositoryList';
enum TabSelection { enum TabSelection {
Repositories = 'repositories', Repositories = 'repositories',

@ -14,7 +14,7 @@ import {
import { t } from 'app/core/internationalization'; import { t } from 'app/core/internationalization';
import { PROVISIONING_URL } from '../constants'; import { PROVISIONING_URL } from '../constants';
import { useCreateOrUpdateRepository } from '../hooks'; import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository';
import { StepStatus } from '../hooks/useStepStatus'; import { StepStatus } from '../hooks/useStepStatus';
import { dataToSpec } from '../utils/data'; import { dataToSpec } from '../utils/data';

@ -1,7 +0,0 @@
export { useCreateOrUpdateRepository } from './useCreateOrUpdateRepository';
export { useCreateOrUpdateRepositoryFile } from './useCreateOrUpdateRepositoryFile';
export { useGetResourceRepository } from './useGetResourceRepository';
export { useIsProvisionedNG } from './useIsProvisionedNG';
export { usePullRequestParam } from './usePullRequestParam';
export { useRepositoryJobs } from './useRepositoryJobs';
export { useRepositoryList } from './useRepositoryList';

@ -1,25 +0,0 @@
import { skipToken } from '@reduxjs/toolkit/query/react';
import { useGetFolderQuery } from '../../../api/clients/folder';
import { AnnoKeyManagerIdentity } from '../../apiserver/types';
import { useRepositoryList } from './useRepositoryList';
interface GetResourceRepositoryArgs {
name?: string;
folderUid?: string;
}
export const useGetResourceRepository = ({ name, folderUid }: GetResourceRepositoryArgs) => {
const [items, isLoading] = useRepositoryList(name || !folderUid ? skipToken : undefined);
// Get the folder data from API to get the repository data for nested folders
const folderQuery = useGetFolderQuery(name || !folderUid ? skipToken : { name: folderUid });
const repoName = name || folderQuery.data?.metadata?.annotations?.[AnnoKeyManagerIdentity];
if (!items?.length || isLoading || !repoName) {
return undefined;
}
return items.find((repo) => repo.metadata?.name === repoName);
};

@ -0,0 +1,82 @@
import { skipToken } from '@reduxjs/toolkit/query/react';
import { Folder, useGetFolderQuery } from 'app/api/clients/folder';
import { RepositoryView, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning';
import { AnnoKeyManagerIdentity } from 'app/features/apiserver/types';
interface GetResourceRepositoryArgs {
name?: string; // the repository name
folderName?: string; // folder we are targeting
}
interface RepositoryViewData {
repository?: RepositoryView;
folder?: Folder;
isLoading?: boolean;
isInstanceManaged: boolean;
}
// This is safe to call as a viewer (you do not need full access to the Repository configs)
export const useGetResourceRepositoryView = ({ name, folderName }: GetResourceRepositoryArgs): RepositoryViewData => {
const { data: settingsData, isLoading: isSettingsLoading } = useGetFrontendSettingsQuery();
const skipFolderQuery = name || !folderName;
const { data: folder, isLoading: isFolderLoading } = useGetFolderQuery(
skipFolderQuery ? skipToken : { name: folderName }
);
if (isSettingsLoading || isFolderLoading) {
return { isLoading: true, isInstanceManaged: false };
}
const items = settingsData?.items ?? [];
if (!items.length) {
return { folder, isInstanceManaged: false };
}
const instanceRepo = items.find((repo) => repo.target === 'instance');
const isInstanceManaged = Boolean(instanceRepo);
if (name) {
const repository = items.find((repo) => repo.name === name);
if (repository) {
return {
repository,
folder,
isInstanceManaged,
};
}
}
// Find the matching folder repository
if (folderName) {
// For root values it will be the same
let repository = items.find((repo) => repo.name === folderName);
if (repository) {
return {
repository,
folder,
isInstanceManaged,
};
}
// For nested folders we need to see what the folder thinks
const annotatedFolderName = folder?.metadata?.annotations?.[AnnoKeyManagerIdentity];
if (annotatedFolderName && name) {
repository = items.find((repo) => repo.name === annotatedFolderName);
if (repository) {
return {
repository,
folder,
isInstanceManaged,
};
}
}
}
return {
repository: instanceRepo,
folder,
isInstanceManaged,
};
};

@ -1,23 +1,17 @@
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning';
import { DashboardScene } from '../../dashboard-scene/scene/DashboardScene'; import { DashboardScene } from '../../dashboard-scene/scene/DashboardScene';
import { useGetResourceRepository } from './useGetResourceRepository'; import { useGetResourceRepositoryView } from './useGetResourceRepositoryView';
export function useIsProvisionedNG(dashboard: DashboardScene): boolean { export function useIsProvisionedNG(dashboard: DashboardScene): boolean {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const folderUid = params.get('folderUid') || undefined; const folderName = params.get('folderUid') || undefined;
const folderRepository = useGetResourceRepository({ folderUid }); const { repository, isInstanceManaged } = useGetResourceRepositoryView({ folderName });
const { data } = useGetFrontendSettingsQuery();
if (!config.featureToggles.provisioning) { if (!config.featureToggles.provisioning) {
return false; return false;
} }
return ( return dashboard.isManagedRepository() || Boolean(repository) || isInstanceManaged;
dashboard.isManagedRepository() ||
Boolean(folderRepository) ||
Boolean(data?.items.some((item) => item.target === 'instance'))
);
} }

Loading…
Cancel
Save