argo-cd
532 строки · 35.8 Кб
1import {AutocompleteField, Checkbox, DataLoader, DropDownMenu, FormField, HelpIcon, Select} from 'argo-ui';
2import * as deepMerge from 'deepmerge';
3import * as React from 'react';
4import {FieldApi, Form, FormApi, FormField as ReactFormField, Text} from 'react-form';
5import {RevisionHelpIcon, YamlEditor} from '../../../shared/components';
6import * as models from '../../../shared/models';
7import {services} from '../../../shared/services';
8import {ApplicationParameters} from '../application-parameters/application-parameters';
9import {ApplicationRetryOptions} from '../application-retry-options/application-retry-options';
10import {ApplicationSyncOptionsField} from '../application-sync-options/application-sync-options';
11import {RevisionFormField} from '../revision-form-field/revision-form-field';
12import {SetFinalizerOnApplication} from './set-finalizer-on-application';
13import './application-create-panel.scss';
14import {getAppDefaultSource} from '../utils';
15
16const jsonMergePatch = require('json-merge-patch');
17
18const appTypes = new Array<{field: string; type: models.AppSourceType}>(
19{type: 'Helm', field: 'helm'},
20{type: 'Kustomize', field: 'kustomize'},
21{type: 'Directory', field: 'directory'},
22{type: 'Plugin', field: 'plugin'}
23);
24
25const DEFAULT_APP: Partial<models.Application> = {
26apiVersion: 'argoproj.io/v1alpha1',
27kind: 'Application',
28metadata: {
29name: ''
30},
31spec: {
32destination: {
33name: '',
34namespace: '',
35server: ''
36},
37source: {
38path: '',
39repoURL: '',
40targetRevision: 'HEAD'
41},
42sources: [],
43project: ''
44}
45};
46
47const AutoSyncFormField = ReactFormField((props: {fieldApi: FieldApi; className: string}) => {
48const manual = 'Manual';
49const auto = 'Automatic';
50const {
51fieldApi: {getValue, setValue}
52} = props;
53const automated = getValue() as models.Automated;
54
55return (
56<React.Fragment>
57<label>Sync Policy</label>
58<Select
59value={automated ? auto : manual}
60options={[manual, auto]}
61onChange={opt => {
62setValue(opt.value === auto ? {prune: false, selfHeal: false} : null);
63}}
64/>
65{automated && (
66<div className='application-create-panel__sync-params'>
67<div className='checkbox-container'>
68<Checkbox onChange={val => setValue({...automated, prune: val})} checked={!!automated.prune} id='policyPrune' />
69<label htmlFor='policyPrune'>Prune Resources</label>
70<HelpIcon title='If checked, Argo will delete resources if they are no longer defined in Git' />
71</div>
72<div className='checkbox-container'>
73<Checkbox onChange={val => setValue({...automated, selfHeal: val})} checked={!!automated.selfHeal} id='policySelfHeal' />
74<label htmlFor='policySelfHeal'>Self Heal</label>
75<HelpIcon title='If checked, Argo will force the state defined in Git into the cluster when a deviation in the cluster is detected' />
76</div>
77</div>
78)}
79</React.Fragment>
80);
81});
82
83function normalizeAppSource(app: models.Application, type: string): boolean {
84const source = getAppDefaultSource(app);
85const repoType = (source.hasOwnProperty('chart') && 'helm') || 'git';
86if (repoType !== type) {
87if (type === 'git') {
88source.path = source.chart;
89delete source.chart;
90source.targetRevision = 'HEAD';
91} else {
92source.chart = source.path;
93delete source.path;
94source.targetRevision = '';
95}
96return true;
97}
98return false;
99}
100
101export const ApplicationCreatePanel = (props: {
102app: models.Application;
103onAppChanged: (app: models.Application) => any;
104createApp: (app: models.Application) => any;
105getFormApi: (api: FormApi) => any;
106}) => {
107const [yamlMode, setYamlMode] = React.useState(false);
108const [explicitPathType, setExplicitPathType] = React.useState<{path: string; type: models.AppSourceType}>(null);
109const [destFormat, setDestFormat] = React.useState('URL');
110const [retry, setRetry] = React.useState(false);
111const app = deepMerge(DEFAULT_APP, props.app || {});
112
113React.useEffect(() => {
114if (app?.spec?.destination?.name && app.spec.destination.name !== '') {
115setDestFormat('NAME');
116} else {
117setDestFormat('URL');
118}
119}, []);
120
121function normalizeTypeFields(formApi: FormApi, type: models.AppSourceType) {
122const appToNormalize = formApi.getFormState().values;
123for (const item of appTypes) {
124if (item.type !== type) {
125delete appToNormalize.spec.source[item.field];
126}
127}
128formApi.setAllValues(appToNormalize);
129}
130
131return (
132<React.Fragment>
133<DataLoader
134key='creation-deps'
135load={() =>
136Promise.all([
137services.projects.list('items.metadata.name').then(projects => projects.map(proj => proj.metadata.name).sort()),
138services.clusters.list().then(clusters => clusters.sort()),
139services.repos.list()
140]).then(([projects, clusters, reposInfo]) => ({projects, clusters, reposInfo}))
141}>
142{({projects, clusters, reposInfo}) => {
143const repos = reposInfo.map(info => info.repo).sort();
144const repoInfo = reposInfo.find(info => info.repo === app.spec.source.repoURL);
145if (repoInfo) {
146normalizeAppSource(app, repoInfo.type || 'git');
147}
148return (
149<div className='application-create-panel'>
150{(yamlMode && (
151<YamlEditor
152minHeight={800}
153initialEditMode={true}
154input={app}
155onCancel={() => setYamlMode(false)}
156onSave={async patch => {
157props.onAppChanged(jsonMergePatch.apply(app, JSON.parse(patch)));
158setYamlMode(false);
159return true;
160}}
161/>
162)) || (
163<Form
164validateError={(a: models.Application) => ({
165'metadata.name': !a.metadata.name && 'Application Name is required',
166'spec.project': !a.spec.project && 'Project Name is required',
167'spec.source.repoURL': !a.spec.source.repoURL && 'Repository URL is required',
168'spec.source.targetRevision': !a.spec.source.targetRevision && a.spec.source.hasOwnProperty('chart') && 'Version is required',
169'spec.source.path': !a.spec.source.path && !a.spec.source.chart && 'Path is required',
170'spec.source.chart': !a.spec.source.path && !a.spec.source.chart && 'Chart is required',
171// Verify cluster URL when there is no cluster name field or the name value is empty
172'spec.destination.server':
173!a.spec.destination.server &&
174(!a.spec.destination.hasOwnProperty('name') || a.spec.destination.name === '') &&
175'Cluster URL is required',
176// Verify cluster name when there is no cluster URL field or the URL value is empty
177'spec.destination.name':
178!a.spec.destination.name &&
179(!a.spec.destination.hasOwnProperty('server') || a.spec.destination.server === '') &&
180'Cluster name is required'
181})}
182defaultValues={app}
183formDidUpdate={state => props.onAppChanged(state.values as any)}
184onSubmit={props.createApp}
185getApi={props.getFormApi}>
186{api => {
187const generalPanel = () => (
188<div className='white-box'>
189<p>GENERAL</p>
190{/*
191Need to specify "type='button'" because the default type 'submit'
192will activate yaml mode whenever enter is pressed while in the panel.
193This causes problems with some entry fields that require enter to be
194pressed for the value to be accepted.
195
196See https://github.com/argoproj/argo-cd/issues/4576
197*/}
198{!yamlMode && (
199<button
200type='button'
201className='argo-button argo-button--base application-create-panel__yaml-button'
202onClick={() => setYamlMode(true)}>
203Edit as YAML
204</button>
205)}
206<div className='argo-form-row'>
207<FormField
208formApi={api}
209label='Application Name'
210qeId='application-create-field-app-name'
211field='metadata.name'
212component={Text}
213/>
214</div>
215<div className='argo-form-row'>
216<FormField
217formApi={api}
218label='Project Name'
219qeId='application-create-field-project'
220field='spec.project'
221component={AutocompleteField}
222componentProps={{
223items: projects,
224filterSuggestions: true
225}}
226/>
227</div>
228<div className='argo-form-row'>
229<FormField
230formApi={api}
231field='spec.syncPolicy.automated'
232qeId='application-create-field-sync-policy'
233component={AutoSyncFormField}
234/>
235</div>
236<div className='argo-form-row'>
237<FormField formApi={api} field='metadata.finalizers' component={SetFinalizerOnApplication} />
238</div>
239<div className='argo-form-row'>
240<label>Sync Options</label>
241<FormField formApi={api} field='spec.syncPolicy.syncOptions' component={ApplicationSyncOptionsField} />
242<ApplicationRetryOptions
243formApi={api}
244field='spec.syncPolicy.retry'
245retry={retry || (api.getFormState().values.spec.syncPolicy && api.getFormState().values.spec.syncPolicy.retry)}
246setRetry={setRetry}
247initValues={api.getFormState().values.spec.syncPolicy ? api.getFormState().values.spec.syncPolicy.retry : null}
248/>
249</div>
250</div>
251);
252
253const repoType = (api.getFormState().values.spec.source.hasOwnProperty('chart') && 'helm') || 'git';
254const sourcePanel = () => (
255<div className='white-box'>
256<p>SOURCE</p>
257<div className='row argo-form-row'>
258<div className='columns small-10'>
259<FormField
260formApi={api}
261label='Repository URL'
262qeId='application-create-field-repository-url'
263field='spec.source.repoURL'
264component={AutocompleteField}
265componentProps={{items: repos}}
266/>
267</div>
268<div className='columns small-2'>
269<div style={{paddingTop: '1.5em'}}>
270{(repoInfo && (
271<React.Fragment>
272<span>{(repoInfo.type || 'git').toUpperCase()}</span> <i className='fa fa-check' />
273</React.Fragment>
274)) || (
275<DropDownMenu
276anchor={() => (
277<p>
278{repoType.toUpperCase()} <i className='fa fa-caret-down' />
279</p>
280)}
281qeId='application-create-dropdown-source-repository'
282items={['git', 'helm'].map((type: 'git' | 'helm') => ({
283title: type.toUpperCase(),
284action: () => {
285if (repoType !== type) {
286const updatedApp = api.getFormState().values as models.Application;
287if (normalizeAppSource(updatedApp, type)) {
288api.setAllValues(updatedApp);
289}
290}
291}
292}))}
293/>
294)}
295</div>
296</div>
297</div>
298{(repoType === 'git' && (
299<React.Fragment>
300<RevisionFormField formApi={api} helpIconTop={'2.5em'} repoURL={app.spec.source.repoURL} />
301<div className='argo-form-row'>
302<DataLoader
303input={{repoURL: app.spec.source.repoURL, revision: app.spec.source.targetRevision}}
304load={async src =>
305(src.repoURL &&
306services.repos
307.apps(src.repoURL, src.revision, app.metadata.name, app.spec.project)
308.then(apps => Array.from(new Set(apps.map(item => item.path))).sort())
309.catch(() => new Array<string>())) ||
310new Array<string>()
311}>
312{(apps: string[]) => (
313<FormField
314formApi={api}
315label='Path'
316qeId='application-create-field-path'
317field='spec.source.path'
318component={AutocompleteField}
319componentProps={{
320items: apps,
321filterSuggestions: true
322}}
323/>
324)}
325</DataLoader>
326</div>
327</React.Fragment>
328)) || (
329<DataLoader
330input={{repoURL: app.spec.source.repoURL}}
331load={async src =>
332(src.repoURL && services.repos.charts(src.repoURL).catch(() => new Array<models.HelmChart>())) ||
333new Array<models.HelmChart>()
334}>
335{(charts: models.HelmChart[]) => {
336const selectedChart = charts.find(chart => chart.name === api.getFormState().values.spec.source.chart);
337return (
338<div className='row argo-form-row'>
339<div className='columns small-10'>
340<FormField
341formApi={api}
342label='Chart'
343field='spec.source.chart'
344component={AutocompleteField}
345componentProps={{
346items: charts.map(chart => chart.name),
347filterSuggestions: true
348}}
349/>
350</div>
351<div className='columns small-2'>
352<FormField
353formApi={api}
354field='spec.source.targetRevision'
355component={AutocompleteField}
356componentProps={{
357items: (selectedChart && selectedChart.versions) || []
358}}
359/>
360<RevisionHelpIcon type='helm' />
361</div>
362</div>
363);
364}}
365</DataLoader>
366)}
367</div>
368);
369const destinationPanel = () => (
370<div className='white-box'>
371<p>DESTINATION</p>
372<div className='row argo-form-row'>
373{(destFormat.toUpperCase() === 'URL' && (
374<div className='columns small-10'>
375<FormField
376formApi={api}
377label='Cluster URL'
378qeId='application-create-field-cluster-url'
379field='spec.destination.server'
380componentProps={{items: clusters.map(cluster => cluster.server)}}
381component={AutocompleteField}
382/>
383</div>
384)) || (
385<div className='columns small-10'>
386<FormField
387formApi={api}
388label='Cluster Name'
389qeId='application-create-field-cluster-name'
390field='spec.destination.name'
391componentProps={{items: clusters.map(cluster => cluster.name)}}
392component={AutocompleteField}
393/>
394</div>
395)}
396<div className='columns small-2'>
397<div style={{paddingTop: '1.5em'}}>
398<DropDownMenu
399anchor={() => (
400<p>
401{destFormat} <i className='fa fa-caret-down' />
402</p>
403)}
404qeId='application-create-dropdown-destination'
405items={['URL', 'NAME'].map((type: 'URL' | 'NAME') => ({
406title: type,
407action: () => {
408if (destFormat !== type) {
409const updatedApp = api.getFormState().values as models.Application;
410if (type === 'URL') {
411delete updatedApp.spec.destination.name;
412} else {
413delete updatedApp.spec.destination.server;
414}
415api.setAllValues(updatedApp);
416setDestFormat(type);
417}
418}
419}))}
420/>
421</div>
422</div>
423</div>
424<div className='argo-form-row'>
425<FormField
426qeId='application-create-field-namespace'
427formApi={api}
428label='Namespace'
429field='spec.destination.namespace'
430component={Text}
431/>
432</div>
433</div>
434);
435
436const typePanel = () => (
437<DataLoader
438input={{
439repoURL: app.spec.source.repoURL,
440path: app.spec.source.path,
441chart: app.spec.source.chart,
442targetRevision: app.spec.source.targetRevision,
443appName: app.metadata.name
444}}
445load={async src => {
446if (src.repoURL && src.targetRevision && (src.path || src.chart)) {
447return services.repos.appDetails(src, src.appName, app.spec.project).catch(() => ({
448type: 'Directory',
449details: {}
450}));
451} else {
452return {
453type: 'Directory',
454details: {}
455};
456}
457}}>
458{(details: models.RepoAppDetails) => {
459const type = (explicitPathType && explicitPathType.path === app.spec.source.path && explicitPathType.type) || details.type;
460if (details.type !== type) {
461switch (type) {
462case 'Helm':
463details = {
464type,
465path: details.path,
466helm: {name: '', valueFiles: [], path: '', parameters: [], fileParameters: []}
467};
468break;
469case 'Kustomize':
470details = {type, path: details.path, kustomize: {path: ''}};
471break;
472case 'Plugin':
473details = {type, path: details.path, plugin: {name: '', env: []}};
474break;
475// Directory
476default:
477details = {type, path: details.path, directory: {}};
478break;
479}
480}
481return (
482<React.Fragment>
483<DropDownMenu
484anchor={() => (
485<p>
486{type} <i className='fa fa-caret-down' />
487</p>
488)}
489qeId='application-create-dropdown-source'
490items={appTypes.map(item => ({
491title: item.type,
492action: () => {
493setExplicitPathType({type: item.type, path: app.spec.source.path});
494normalizeTypeFields(api, item.type);
495}
496}))}
497/>
498<ApplicationParameters
499noReadonlyMode={true}
500application={app}
501details={details}
502save={async updatedApp => {
503api.setAllValues(updatedApp);
504}}
505/>
506</React.Fragment>
507);
508}}
509</DataLoader>
510);
511
512return (
513<form onSubmit={api.submitForm} role='form' className='width-control'>
514{generalPanel()}
515
516{sourcePanel()}
517
518{destinationPanel()}
519
520{typePanel()}
521</form>
522);
523}}
524</Form>
525)}
526</div>
527);
528}}
529</DataLoader>
530</React.Fragment>
531);
532};
533