github.com/argoproj/argo-cd/v2@v2.10.9/ui/src/app/applications/components/application-parameters/application-parameters.tsx (about)

     1  import {AutocompleteField, DataLoader, FormField, FormSelect, getNestedField} from 'argo-ui';
     2  import * as React from 'react';
     3  import {FieldApi, FormApi, FormField as ReactFormField, Text, TextArea} from 'react-form';
     4  import {cloneDeep} from 'lodash-es';
     5  import {
     6      ArrayInputField,
     7      ArrayValueField,
     8      CheckboxField,
     9      EditablePanel,
    10      EditablePanelItem,
    11      Expandable,
    12      MapValueField,
    13      NameValueEditor,
    14      StringValueField,
    15      NameValue,
    16      TagsInputField,
    17      ValueEditor
    18  } from '../../../shared/components';
    19  import * as models from '../../../shared/models';
    20  import {ApplicationSourceDirectory, Plugin} from '../../../shared/models';
    21  import {services} from '../../../shared/services';
    22  import {ImageTagFieldEditor} from './kustomize';
    23  import * as kustomize from './kustomize-image';
    24  import {VarsInputField} from './vars-input-field';
    25  import {concatMaps} from '../../../shared/utils';
    26  import {getAppDefaultSource} from '../utils';
    27  import * as jsYaml from 'js-yaml';
    28  
    29  const TextWithMetadataField = ReactFormField((props: {metadata: {value: string}; fieldApi: FieldApi; className: string}) => {
    30      const {
    31          fieldApi: {getValue, setValue}
    32      } = props;
    33      const metadata = getValue() || props.metadata;
    34  
    35      return <input className={props.className} value={metadata.value} onChange={el => setValue({...metadata, value: el.target.value})} />;
    36  });
    37  
    38  function distinct<T>(first: IterableIterator<T>, second: IterableIterator<T>) {
    39      return Array.from(new Set(Array.from(first).concat(Array.from(second))));
    40  }
    41  
    42  function overridesFirst(first: {overrideIndex: number; metadata: {name: string}}, second: {overrideIndex: number; metadata: {name: string}}) {
    43      if (first.overrideIndex === second.overrideIndex) {
    44          return first.metadata.name.localeCompare(second.metadata.name);
    45      }
    46      if (first.overrideIndex < 0) {
    47          return 1;
    48      } else if (second.overrideIndex < 0) {
    49          return -1;
    50      }
    51      return first.overrideIndex - second.overrideIndex;
    52  }
    53  
    54  function getParamsEditableItems(
    55      app: models.Application,
    56      title: string,
    57      fieldsPath: string,
    58      removedOverrides: boolean[],
    59      setRemovedOverrides: React.Dispatch<boolean[]>,
    60      params: {
    61          key?: string;
    62          overrideIndex: number;
    63          original: string;
    64          metadata: {name: string; value: string};
    65      }[],
    66      component: React.ComponentType = TextWithMetadataField
    67  ) {
    68      return params
    69          .sort(overridesFirst)
    70          .map((param, i) => ({
    71              key: param.key,
    72              title: param.metadata.name,
    73              view: (
    74                  <span title={param.metadata.value}>
    75                      {param.overrideIndex > -1 && <span className='fa fa-gavel' title={`Original value: ${param.original}`} />} {param.metadata.value}
    76                  </span>
    77              ),
    78              edit: (formApi: FormApi) => {
    79                  const labelStyle = {position: 'absolute', right: 0, top: 0, zIndex: 11} as any;
    80                  const overrideRemoved = removedOverrides[i];
    81                  const fieldItemPath = `${fieldsPath}[${i}]`;
    82                  return (
    83                      <React.Fragment>
    84                          {(overrideRemoved && <span>{param.original}</span>) || (
    85                              <FormField
    86                                  formApi={formApi}
    87                                  field={fieldItemPath}
    88                                  component={component}
    89                                  componentProps={{
    90                                      metadata: param.metadata
    91                                  }}
    92                              />
    93                          )}
    94                          {param.metadata.value !== param.original && !overrideRemoved && (
    95                              <a
    96                                  onClick={() => {
    97                                      formApi.setValue(fieldItemPath, null);
    98                                      removedOverrides[i] = true;
    99                                      setRemovedOverrides(removedOverrides);
   100                                  }}
   101                                  style={labelStyle}>
   102                                  Remove override
   103                              </a>
   104                          )}
   105                          {overrideRemoved && (
   106                              <a
   107                                  onClick={() => {
   108                                      formApi.setValue(fieldItemPath, getNestedField(app, fieldsPath)[i]);
   109                                      removedOverrides[i] = false;
   110                                      setRemovedOverrides(removedOverrides);
   111                                  }}
   112                                  style={labelStyle}>
   113                                  Keep override
   114                              </a>
   115                          )}
   116                      </React.Fragment>
   117                  );
   118              }
   119          }))
   120          .map((item, i) => ({...item, before: (i === 0 && <p style={{marginTop: '1em'}}>{title}</p>) || null}));
   121  }
   122  
   123  export const ApplicationParameters = (props: {
   124      application: models.Application;
   125      details: models.RepoAppDetails;
   126      save?: (application: models.Application, query: {validate?: boolean}) => Promise<any>;
   127      noReadonlyMode?: boolean;
   128  }) => {
   129      const app = cloneDeep(props.application);
   130      const source = getAppDefaultSource(app);
   131      const [removedOverrides, setRemovedOverrides] = React.useState(new Array<boolean>());
   132  
   133      let attributes: EditablePanelItem[] = [];
   134      const isValuesObject = source?.helm?.valuesObject;
   135      const helmValues = isValuesObject ? jsYaml.safeDump(source.helm.valuesObject) : source?.helm?.values;
   136      const [appParamsDeletedState, setAppParamsDeletedState] = React.useState([]);
   137  
   138      if (props.details.type === 'Kustomize' && props.details.kustomize) {
   139          attributes.push({
   140              title: 'VERSION',
   141              view: (source.kustomize && source.kustomize.version) || <span>default</span>,
   142              edit: (formApi: FormApi) => (
   143                  <DataLoader load={() => services.authService.settings()}>
   144                      {settings =>
   145                          ((settings.kustomizeVersions || []).length > 0 && (
   146                              <FormField formApi={formApi} field='spec.source.kustomize.version' component={AutocompleteField} componentProps={{items: settings.kustomizeVersions}} />
   147                          )) || <span>default</span>
   148                      }
   149                  </DataLoader>
   150              )
   151          });
   152  
   153          attributes.push({
   154              title: 'NAME PREFIX',
   155              view: source.kustomize && source.kustomize.namePrefix,
   156              edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.source.kustomize.namePrefix' component={Text} />
   157          });
   158  
   159          attributes.push({
   160              title: 'NAME SUFFIX',
   161              view: source.kustomize && source.kustomize.nameSuffix,
   162              edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.source.kustomize.nameSuffix' component={Text} />
   163          });
   164  
   165          attributes.push({
   166              title: 'NAMESPACE',
   167              view: app.spec.source.kustomize && app.spec.source.kustomize.namespace,
   168              edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.source.kustomize.namespace' component={Text} />
   169          });
   170  
   171          const srcImages = ((props.details && props.details.kustomize && props.details.kustomize.images) || []).map(val => kustomize.parse(val));
   172          const images = ((source.kustomize && source.kustomize.images) || []).map(val => kustomize.parse(val));
   173  
   174          if (srcImages.length > 0) {
   175              const imagesByName = new Map<string, kustomize.Image>();
   176              srcImages.forEach(img => imagesByName.set(img.name, img));
   177  
   178              const overridesByName = new Map<string, number>();
   179              images.forEach((override, i) => overridesByName.set(override.name, i));
   180  
   181              attributes = attributes.concat(
   182                  getParamsEditableItems(
   183                      app,
   184                      'IMAGES',
   185                      'spec.source.kustomize.images',
   186                      removedOverrides,
   187                      setRemovedOverrides,
   188                      distinct(imagesByName.keys(), overridesByName.keys()).map(name => {
   189                          const param = imagesByName.get(name);
   190                          const original = param && kustomize.format(param);
   191                          let overrideIndex = overridesByName.get(name);
   192                          if (overrideIndex === undefined) {
   193                              overrideIndex = -1;
   194                          }
   195                          const value = (overrideIndex > -1 && kustomize.format(images[overrideIndex])) || original;
   196                          return {overrideIndex, original, metadata: {name, value}};
   197                      }),
   198                      ImageTagFieldEditor
   199                  )
   200              );
   201          }
   202      } else if (props.details.type === 'Helm' && props.details.helm) {
   203          attributes.push({
   204              title: 'VALUES FILES',
   205              view: (source.helm && (source.helm.valueFiles || []).join(', ')) || 'No values files selected',
   206              edit: (formApi: FormApi) => (
   207                  <FormField
   208                      formApi={formApi}
   209                      field='spec.source.helm.valueFiles'
   210                      component={TagsInputField}
   211                      componentProps={{
   212                          options: props.details.helm.valueFiles,
   213                          noTagsLabel: 'No values files selected'
   214                      }}
   215                  />
   216              )
   217          });
   218          attributes.push({
   219              title: 'VALUES',
   220              view: source.helm && (
   221                  <Expandable>
   222                      <pre>{helmValues}</pre>
   223                  </Expandable>
   224              ),
   225              edit: (formApi: FormApi) => {
   226                  // In case source.helm.valuesObject is set, set source.helm.values to its value
   227                  if (source.helm) {
   228                      source.helm.values = helmValues;
   229                  }
   230  
   231                  return (
   232                      <div>
   233                          <pre>
   234                              <FormField formApi={formApi} field='spec.source.helm.values' component={TextArea} />
   235                          </pre>
   236                      </div>
   237                  );
   238              }
   239          });
   240          const paramsByName = new Map<string, models.HelmParameter>();
   241          (props.details.helm.parameters || []).forEach(param => paramsByName.set(param.name, param));
   242          const overridesByName = new Map<string, number>();
   243          ((source.helm && source.helm.parameters) || []).forEach((override, i) => overridesByName.set(override.name, i));
   244          attributes = attributes.concat(
   245              getParamsEditableItems(
   246                  app,
   247                  'PARAMETERS',
   248                  'spec.source.helm.parameters',
   249                  removedOverrides,
   250                  setRemovedOverrides,
   251                  distinct(paramsByName.keys(), overridesByName.keys()).map(name => {
   252                      const param = paramsByName.get(name);
   253                      const original = (param && param.value) || '';
   254                      let overrideIndex = overridesByName.get(name);
   255                      if (overrideIndex === undefined) {
   256                          overrideIndex = -1;
   257                      }
   258                      const value = (overrideIndex > -1 && source.helm.parameters[overrideIndex].value) || original;
   259                      return {overrideIndex, original, metadata: {name, value}};
   260                  })
   261              )
   262          );
   263          const fileParamsByName = new Map<string, models.HelmFileParameter>();
   264          (props.details.helm.fileParameters || []).forEach(param => fileParamsByName.set(param.name, param));
   265          const fileOverridesByName = new Map<string, number>();
   266          ((source.helm && source.helm.fileParameters) || []).forEach((override, i) => fileOverridesByName.set(override.name, i));
   267          attributes = attributes.concat(
   268              getParamsEditableItems(
   269                  app,
   270                  'PARAMETERS',
   271                  'spec.source.helm.parameters',
   272                  removedOverrides,
   273                  setRemovedOverrides,
   274                  distinct(fileParamsByName.keys(), fileOverridesByName.keys()).map(name => {
   275                      const param = fileParamsByName.get(name);
   276                      const original = (param && param.path) || '';
   277                      let overrideIndex = fileOverridesByName.get(name);
   278                      if (overrideIndex === undefined) {
   279                          overrideIndex = -1;
   280                      }
   281                      const value = (overrideIndex > -1 && source.helm.fileParameters[overrideIndex].path) || original;
   282                      return {overrideIndex, original, metadata: {name, value}};
   283                  })
   284              )
   285          );
   286      } else if (props.details.type === 'Plugin') {
   287          attributes.push({
   288              title: 'NAME',
   289              view: <div style={{marginTop: 15, marginBottom: 5}}>{ValueEditor(app.spec.source?.plugin?.name, null)}</div>,
   290              edit: (formApi: FormApi) => (
   291                  <DataLoader load={() => services.authService.plugins()}>
   292                      {(plugins: Plugin[]) => (
   293                          <FormField formApi={formApi} field='spec.source.plugin.name' component={FormSelect} componentProps={{options: plugins.map(p => p.name)}} />
   294                      )}
   295                  </DataLoader>
   296              )
   297          });
   298          attributes.push({
   299              title: 'ENV',
   300              view: (
   301                  <div style={{marginTop: 15}}>
   302                      {(app.spec.source?.plugin?.env || []).map(val => (
   303                          <span key={val.name} style={{display: 'block', marginBottom: 5}}>
   304                              {NameValueEditor(val, null)}
   305                          </span>
   306                      ))}
   307                  </div>
   308              ),
   309              edit: (formApi: FormApi) => <FormField field='spec.source.plugin.env' formApi={formApi} component={ArrayInputField} />
   310          });
   311          const parametersSet = new Set<string>();
   312          if (props.details?.plugin?.parametersAnnouncement) {
   313              for (const announcement of props.details.plugin.parametersAnnouncement) {
   314                  parametersSet.add(announcement.name);
   315              }
   316          }
   317          if (app.spec.source?.plugin?.parameters) {
   318              for (const appParameter of app.spec.source.plugin.parameters) {
   319                  parametersSet.add(appParameter.name);
   320              }
   321          }
   322  
   323          for (const key of appParamsDeletedState) {
   324              parametersSet.delete(key);
   325          }
   326          parametersSet.forEach(name => {
   327              const announcement = props.details.plugin.parametersAnnouncement?.find(param => param.name === name);
   328              const liveParam = app.spec.source?.plugin?.parameters?.find(param => param.name === name);
   329              const pluginIcon =
   330                  announcement && liveParam ? 'This parameter has been provided by plugin, but is overridden in application manifest.' : 'This parameter is provided by the plugin.';
   331              const isPluginPar = !!announcement;
   332              if ((announcement?.collectionType === undefined && liveParam?.map) || announcement?.collectionType === 'map') {
   333                  let liveParamMap;
   334                  if (liveParam) {
   335                      liveParamMap = liveParam.map ?? new Map<string, string>();
   336                  }
   337                  const map = concatMaps(liveParamMap ?? announcement?.map, new Map<string, string>());
   338                  const entries = map.entries();
   339                  const items = new Array<NameValue>();
   340                  Array.from(entries).forEach(([key, value]) => items.push({name: key, value: `${value}`}));
   341                  attributes.push({
   342                      title: announcement?.title ?? announcement?.name ?? name,
   343                      customTitle: (
   344                          <span>
   345                              {isPluginPar && <i className='fa solid fa-puzzle-piece' title={pluginIcon} style={{marginRight: 5}} />}
   346                              {announcement?.title ?? announcement?.name ?? name}
   347                          </span>
   348                      ),
   349                      view: (
   350                          <div style={{marginTop: 15, marginBottom: 5}}>
   351                              {items.length === 0 && <span style={{color: 'dimgray'}}>-- NO ITEMS --</span>}
   352                              {items.map(val => (
   353                                  <span key={val.name} style={{display: 'block', marginBottom: 5}}>
   354                                      {NameValueEditor(val)}
   355                                  </span>
   356                              ))}
   357                          </div>
   358                      ),
   359                      edit: (formApi: FormApi) => (
   360                          <FormField
   361                              field='spec.source.plugin.parameters'
   362                              componentProps={{
   363                                  name: announcement?.name ?? name,
   364                                  defaultVal: announcement?.map,
   365                                  isPluginPar,
   366                                  setAppParamsDeletedState
   367                              }}
   368                              formApi={formApi}
   369                              component={MapValueField}
   370                          />
   371                      )
   372                  });
   373              } else if ((announcement?.collectionType === undefined && liveParam?.array) || announcement?.collectionType === 'array') {
   374                  let liveParamArray;
   375                  if (liveParam) {
   376                      liveParamArray = liveParam?.array ?? [];
   377                  }
   378                  attributes.push({
   379                      title: announcement?.title ?? announcement?.name ?? name,
   380                      customTitle: (
   381                          <span>
   382                              {isPluginPar && <i className='fa-solid fa-puzzle-piece' title={pluginIcon} style={{marginRight: 5}} />}
   383                              {announcement?.title ?? announcement?.name ?? name}
   384                          </span>
   385                      ),
   386                      view: (
   387                          <div style={{marginTop: 15, marginBottom: 5}}>
   388                              {(liveParamArray ?? announcement?.array ?? []).length === 0 && <span style={{color: 'dimgray'}}>-- NO ITEMS --</span>}
   389                              {(liveParamArray ?? announcement?.array ?? []).map((val, index) => (
   390                                  <span key={index} style={{display: 'block', marginBottom: 5}}>
   391                                      {ValueEditor(val, null)}
   392                                  </span>
   393                              ))}
   394                          </div>
   395                      ),
   396                      edit: (formApi: FormApi) => (
   397                          <FormField
   398                              field='spec.source.plugin.parameters'
   399                              componentProps={{
   400                                  name: announcement?.name ?? name,
   401                                  defaultVal: announcement?.array,
   402                                  isPluginPar,
   403                                  setAppParamsDeletedState
   404                              }}
   405                              formApi={formApi}
   406                              component={ArrayValueField}
   407                          />
   408                      )
   409                  });
   410              } else if (
   411                  (announcement?.collectionType === undefined && liveParam?.string) ||
   412                  announcement?.collectionType === '' ||
   413                  announcement?.collectionType === 'string' ||
   414                  announcement?.collectionType === undefined
   415              ) {
   416                  let liveParamString;
   417                  if (liveParam) {
   418                      liveParamString = liveParam?.string ?? '';
   419                  }
   420                  attributes.push({
   421                      title: announcement?.title ?? announcement?.name ?? name,
   422                      customTitle: (
   423                          <span>
   424                              {isPluginPar && <i className='fa-solid fa-puzzle-piece' title={pluginIcon} style={{marginRight: 5}} />}
   425                              {announcement?.title ?? announcement?.name ?? name}
   426                          </span>
   427                      ),
   428                      view: (
   429                          <div
   430                              style={{
   431                                  marginTop: 15,
   432                                  marginBottom: 5
   433                              }}>
   434                              {ValueEditor(liveParamString ?? announcement?.string, null)}
   435                          </div>
   436                      ),
   437                      edit: (formApi: FormApi) => (
   438                          <FormField
   439                              field='spec.source.plugin.parameters'
   440                              componentProps={{
   441                                  name: announcement?.name ?? name,
   442                                  defaultVal: announcement?.string,
   443                                  isPluginPar,
   444                                  setAppParamsDeletedState
   445                              }}
   446                              formApi={formApi}
   447                              component={StringValueField}
   448                          />
   449                      )
   450                  });
   451              }
   452          });
   453      } else if (props.details.type === 'Directory') {
   454          const directory = source.directory || ({} as ApplicationSourceDirectory);
   455          attributes.push({
   456              title: 'DIRECTORY RECURSE',
   457              view: (!!directory.recurse).toString(),
   458              edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.source.directory.recurse' component={CheckboxField} />
   459          });
   460          attributes.push({
   461              title: 'TOP-LEVEL ARGUMENTS',
   462              view: ((directory?.jsonnet && directory?.jsonnet.tlas) || []).map((i, j) => (
   463                  <label key={j}>
   464                      {i.name}='{i.value}' {i.code && 'code'}
   465                  </label>
   466              )),
   467              edit: (formApi: FormApi) => <FormField field='spec.source.directory.jsonnet.tlas' formApi={formApi} component={VarsInputField} />
   468          });
   469          attributes.push({
   470              title: 'EXTERNAL VARIABLES',
   471              view: ((directory.jsonnet && directory.jsonnet.extVars) || []).map((i, j) => (
   472                  <label key={j}>
   473                      {i.name}='{i.value}' {i.code && 'code'}
   474                  </label>
   475              )),
   476              edit: (formApi: FormApi) => <FormField field='spec.source.directory.jsonnet.extVars' formApi={formApi} component={VarsInputField} />
   477          });
   478  
   479          attributes.push({
   480              title: 'INCLUDE',
   481              view: directory && directory.include,
   482              edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.source.directory.include' component={Text} />
   483          });
   484  
   485          attributes.push({
   486              title: 'EXCLUDE',
   487              view: directory && directory.exclude,
   488              edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.source.directory.exclude' component={Text} />
   489          });
   490      }
   491  
   492      return (
   493          <EditablePanel
   494              save={
   495                  props.save &&
   496                  (async (input: models.Application) => {
   497                      const src = getAppDefaultSource(input);
   498  
   499                      function isDefined(item: any) {
   500                          return item !== null && item !== undefined;
   501                      }
   502                      function isDefinedWithVersion(item: any) {
   503                          return item !== null && item !== undefined && item.match(/:/);
   504                      }
   505  
   506                      if (src.helm && src.helm.parameters) {
   507                          src.helm.parameters = src.helm.parameters.filter(isDefined);
   508                      }
   509                      if (src.kustomize && src.kustomize.images) {
   510                          src.kustomize.images = src.kustomize.images.filter(isDefinedWithVersion);
   511                      }
   512  
   513                      let params = input.spec?.source?.plugin?.parameters;
   514                      if (params) {
   515                          for (const param of params) {
   516                              if (param.map && param.array) {
   517                                  // @ts-ignore
   518                                  param.map = param.array.reduce((acc, {name, value}) => {
   519                                      // @ts-ignore
   520                                      acc[name] = value;
   521                                      return acc;
   522                                  }, {});
   523                                  delete param.array;
   524                              }
   525                          }
   526  
   527                          params = params.filter(param => !appParamsDeletedState.includes(param.name));
   528                          input.spec.source.plugin.parameters = params;
   529                      }
   530                      if (input.spec.source.helm && input.spec.source.helm.valuesObject) {
   531                          input.spec.source.helm.valuesObject = jsYaml.safeLoad(input.spec.source.helm.values); // Deserialize json
   532                          input.spec.source.helm.values = '';
   533                      }
   534                      await props.save(input, {});
   535                      setRemovedOverrides(new Array<boolean>());
   536                  })
   537              }
   538              values={((props.details.plugin || app?.spec?.source?.plugin) && cloneDeep(app)) || app}
   539              validate={updatedApp => {
   540                  const errors = {} as any;
   541  
   542                  for (const fieldPath of ['spec.source.directory.jsonnet.tlas', 'spec.source.directory.jsonnet.extVars']) {
   543                      const invalid = ((getNestedField(updatedApp, fieldPath) || []) as Array<models.JsonnetVar>).filter(item => !item.name && !item.code);
   544                      errors[fieldPath] = invalid.length > 0 ? 'All fields must have name' : null;
   545                  }
   546  
   547                  if (updatedApp.spec.source.helm && updatedApp.spec.source.helm.values) {
   548                      const parsedValues = jsYaml.safeLoad(updatedApp.spec.source.helm.values);
   549                      errors['spec.source.helm.values'] = typeof parsedValues === 'object' ? null : 'Values must be a map';
   550                  }
   551  
   552                  return errors;
   553              }}
   554              onModeSwitch={
   555                  props.details.plugin &&
   556                  (() => {
   557                      setAppParamsDeletedState([]);
   558                  })
   559              }
   560              title={props.details.type.toLocaleUpperCase()}
   561              items={attributes}
   562              noReadonlyMode={props.noReadonlyMode}
   563              hasMultipleSources={app.spec.sources && app.spec.sources.length > 0}
   564          />
   565      );
   566  };