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

     1  import {AutocompleteField, DropDownMenu, ErrorNotification, FormField, FormSelect, HelpIcon, NotificationType} from 'argo-ui';
     2  import * as React from 'react';
     3  import {FormApi, Text} from 'react-form';
     4  import {
     5      ClipboardText,
     6      Cluster,
     7      DataLoader,
     8      EditablePanel,
     9      EditablePanelItem,
    10      Expandable,
    11      MapInputField,
    12      NumberField,
    13      Repo,
    14      Revision,
    15      RevisionHelpIcon
    16  } from '../../../shared/components';
    17  import {BadgePanel, Spinner} from '../../../shared/components';
    18  import {AuthSettingsCtx, Consumer, ContextApis} from '../../../shared/context';
    19  import * as models from '../../../shared/models';
    20  import {services} from '../../../shared/services';
    21  
    22  import {ApplicationSyncOptionsField} from '../application-sync-options/application-sync-options';
    23  import {RevisionFormField} from '../revision-form-field/revision-form-field';
    24  import {ComparisonStatusIcon, HealthStatusIcon, syncStatusMessage, urlPattern, formatCreationTimestamp, getAppDefaultSource, getAppSpecDefaultSource, helpTip} from '../utils';
    25  import {ApplicationRetryOptions} from '../application-retry-options/application-retry-options';
    26  import {ApplicationRetryView} from '../application-retry-view/application-retry-view';
    27  import {Link} from 'react-router-dom';
    28  import {EditNotificationSubscriptions, useEditNotificationSubscriptions} from './edit-notification-subscriptions';
    29  import {EditAnnotations} from './edit-annotations';
    30  
    31  import './application-summary.scss';
    32  import {DeepLinks} from '../../../shared/components/deep-links';
    33  import {ExternalLinks} from '../application-urls';
    34  
    35  function swap(array: any[], a: number, b: number) {
    36      array = array.slice();
    37      [array[a], array[b]] = [array[b], array[a]];
    38      return array;
    39  }
    40  
    41  export interface ApplicationSummaryProps {
    42      app: models.Application;
    43      updateApp: (app: models.Application, query: {validate?: boolean}) => Promise<any>;
    44  }
    45  
    46  export const ApplicationSummary = (props: ApplicationSummaryProps) => {
    47      const app = JSON.parse(JSON.stringify(props.app)) as models.Application;
    48      const source = getAppDefaultSource(app);
    49      const isHelm = source.hasOwnProperty('chart');
    50      const initialState = app.spec.destination.server === undefined ? 'NAME' : 'URL';
    51      const useAuthSettingsCtx = React.useContext(AuthSettingsCtx);
    52      const [destFormat, setDestFormat] = React.useState(initialState);
    53      const [changeSync, setChangeSync] = React.useState(false);
    54  
    55      const notificationSubscriptions = useEditNotificationSubscriptions(app.metadata.annotations || {});
    56      const updateApp = notificationSubscriptions.withNotificationSubscriptions(props.updateApp);
    57  
    58      const hasMultipleSources = app.spec.sources && app.spec.sources.length > 0;
    59  
    60      const attributes = [
    61          {
    62              title: 'PROJECT',
    63              view: <Link to={'/settings/projects/' + app.spec.project}>{app.spec.project}</Link>,
    64              edit: (formApi: FormApi) => (
    65                  <DataLoader load={() => services.projects.list('items.metadata.name').then(projs => projs.map(item => item.metadata.name))}>
    66                      {projects => <FormField formApi={formApi} field='spec.project' component={FormSelect} componentProps={{options: projects}} />}
    67                  </DataLoader>
    68              )
    69          },
    70          {
    71              title: 'LABELS',
    72              view: Object.keys(app.metadata.labels || {})
    73                  .map(label => `${label}=${app.metadata.labels[label]}`)
    74                  .join(' '),
    75              edit: (formApi: FormApi) => <FormField formApi={formApi} field='metadata.labels' component={MapInputField} />
    76          },
    77          {
    78              title: 'ANNOTATIONS',
    79              view: (
    80                  <Expandable height={48}>
    81                      {Object.keys(app.metadata.annotations || {})
    82                          .map(annotation => `${annotation}=${app.metadata.annotations[annotation]}`)
    83                          .join(' ')}
    84                  </Expandable>
    85              ),
    86              edit: (formApi: FormApi) => <EditAnnotations formApi={formApi} app={app} />
    87          },
    88          {
    89              title: 'NOTIFICATION SUBSCRIPTIONS',
    90              view: false, // eventually the subscription input values will be merged in 'ANNOTATIONS', therefore 'ANNOATIONS' section is responsible to represent subscription values,
    91              edit: () => <EditNotificationSubscriptions {...notificationSubscriptions} />
    92          },
    93          {
    94              title: 'CLUSTER',
    95              view: <Cluster server={app.spec.destination.server} name={app.spec.destination.name} showUrl={true} />,
    96              edit: (formApi: FormApi) => (
    97                  <DataLoader load={() => services.clusters.list().then(clusters => clusters.sort())}>
    98                      {clusters => {
    99                          return (
   100                              <div className='row'>
   101                                  {(destFormat.toUpperCase() === 'URL' && (
   102                                      <div className='columns small-10'>
   103                                          <FormField
   104                                              formApi={formApi}
   105                                              field='spec.destination.server'
   106                                              componentProps={{items: clusters.map(cluster => cluster.server)}}
   107                                              component={AutocompleteField}
   108                                          />
   109                                      </div>
   110                                  )) || (
   111                                      <div className='columns small-10'>
   112                                          <FormField
   113                                              formApi={formApi}
   114                                              field='spec.destination.name'
   115                                              componentProps={{items: clusters.map(cluster => cluster.name)}}
   116                                              component={AutocompleteField}
   117                                          />
   118                                      </div>
   119                                  )}
   120                                  <div className='columns small-2'>
   121                                      <div>
   122                                          <DropDownMenu
   123                                              anchor={() => (
   124                                                  <p>
   125                                                      {destFormat.toUpperCase()} <i className='fa fa-caret-down' />
   126                                                  </p>
   127                                              )}
   128                                              items={['URL', 'NAME'].map((type: 'URL' | 'NAME') => ({
   129                                                  title: type,
   130                                                  action: () => {
   131                                                      if (destFormat !== type) {
   132                                                          const updatedApp = formApi.getFormState().values as models.Application;
   133                                                          if (type === 'URL') {
   134                                                              updatedApp.spec.destination.server = '';
   135                                                              delete updatedApp.spec.destination.name;
   136                                                          } else {
   137                                                              updatedApp.spec.destination.name = '';
   138                                                              delete updatedApp.spec.destination.server;
   139                                                          }
   140                                                          formApi.setAllValues(updatedApp);
   141                                                          setDestFormat(type);
   142                                                      }
   143                                                  }
   144                                              }))}
   145                                          />
   146                                      </div>
   147                                  </div>
   148                              </div>
   149                          );
   150                      }}
   151                  </DataLoader>
   152              )
   153          },
   154          {
   155              title: 'NAMESPACE',
   156              view: <ClipboardText text={app.spec.destination.namespace} />,
   157              edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.destination.namespace' component={Text} />
   158          },
   159          {
   160              title: 'CREATED AT',
   161              view: formatCreationTimestamp(app.metadata.creationTimestamp)
   162          },
   163          {
   164              title: 'REPO URL',
   165              view: <Repo url={source.repoURL} />,
   166              edit: (formApi: FormApi) =>
   167                  hasMultipleSources ? (
   168                      helpTip('REPO URL is not editable for applications with multiple sources. You can edit them in the "Manifest" tab.')
   169                  ) : (
   170                      <FormField formApi={formApi} field='spec.source.repoURL' component={Text} />
   171                  )
   172          },
   173          ...(isHelm
   174              ? [
   175                    {
   176                        title: 'CHART',
   177                        view: (
   178                            <span>
   179                                {source.chart}:{source.targetRevision}
   180                            </span>
   181                        ),
   182                        edit: (formApi: FormApi) =>
   183                            hasMultipleSources ? (
   184                                helpTip('CHART is not editable for applications with multiple sources. You can edit them in the "Manifest" tab.')
   185                            ) : (
   186                                <DataLoader
   187                                    input={{repoURL: getAppSpecDefaultSource(formApi.getFormState().values.spec).repoURL}}
   188                                    load={src => services.repos.charts(src.repoURL).catch(() => new Array<models.HelmChart>())}>
   189                                    {(charts: models.HelmChart[]) => (
   190                                        <div className='row'>
   191                                            <div className='columns small-8'>
   192                                                <FormField
   193                                                    formApi={formApi}
   194                                                    field='spec.source.chart'
   195                                                    component={AutocompleteField}
   196                                                    componentProps={{
   197                                                        items: charts.map(chart => chart.name),
   198                                                        filterSuggestions: true
   199                                                    }}
   200                                                />
   201                                            </div>
   202                                            <DataLoader
   203                                                input={{charts, chart: getAppSpecDefaultSource(formApi.getFormState().values.spec).chart}}
   204                                                load={async data => {
   205                                                    const chartInfo = data.charts.find(chart => chart.name === data.chart);
   206                                                    return (chartInfo && chartInfo.versions) || new Array<string>();
   207                                                }}>
   208                                                {(versions: string[]) => (
   209                                                    <div className='columns small-4'>
   210                                                        <FormField
   211                                                            formApi={formApi}
   212                                                            field='spec.source.targetRevision'
   213                                                            component={AutocompleteField}
   214                                                            componentProps={{
   215                                                                items: versions
   216                                                            }}
   217                                                        />
   218                                                        <RevisionHelpIcon type='helm' top='0' />
   219                                                    </div>
   220                                                )}
   221                                            </DataLoader>
   222                                        </div>
   223                                    )}
   224                                </DataLoader>
   225                            )
   226                    }
   227                ]
   228              : [
   229                    {
   230                        title: 'TARGET REVISION',
   231                        view: <Revision repoUrl={source.repoURL} revision={source.targetRevision || 'HEAD'} />,
   232                        edit: (formApi: FormApi) =>
   233                            hasMultipleSources ? (
   234                                helpTip('TARGET REVISION is not editable for applications with multiple sources. You can edit them in the "Manifest" tab.')
   235                            ) : (
   236                                <RevisionFormField helpIconTop={'0'} hideLabel={true} formApi={formApi} repoURL={source.repoURL} />
   237                            )
   238                    },
   239                    {
   240                        title: 'PATH',
   241                        view: (
   242                            <Revision repoUrl={source.repoURL} revision={source.targetRevision || 'HEAD'} path={source.path} isForPath={true}>
   243                                {source.path ?? ''}
   244                            </Revision>
   245                        ),
   246                        edit: (formApi: FormApi) =>
   247                            hasMultipleSources ? (
   248                                helpTip('PATH is not editable for applications with multiple sources. You can edit them in the "Manifest" tab.')
   249                            ) : (
   250                                <FormField formApi={formApi} field='spec.source.path' component={Text} />
   251                            )
   252                    }
   253                ]),
   254  
   255          {
   256              title: 'REVISION HISTORY LIMIT',
   257              view: app.spec.revisionHistoryLimit,
   258              edit: (formApi: FormApi) => (
   259                  <div style={{position: 'relative'}}>
   260                      <FormField formApi={formApi} field='spec.revisionHistoryLimit' componentProps={{style: {paddingRight: '1em'}, placeholder: '10'}} component={NumberField} />
   261                      <div style={{position: 'absolute', right: '0', top: '0'}}>
   262                          <HelpIcon
   263                              title='This limits the number of items kept in the apps revision history.
   264      This should only be changed in exceptional circumstances.
   265      Setting to zero will store no history. This will reduce storage used.
   266      Increasing will increase the space used to store the history, so we do not recommend increasing it.
   267      Default is 10.'
   268                          />
   269                      </div>
   270                  </div>
   271              )
   272          },
   273          {
   274              title: 'SYNC OPTIONS',
   275              view: (
   276                  <div style={{display: 'flex', flexWrap: 'wrap'}}>
   277                      {((app.spec.syncPolicy || {}).syncOptions || []).map(opt =>
   278                          opt.endsWith('=true') || opt.endsWith('=false') ? (
   279                              <div key={opt} style={{marginRight: '10px'}}>
   280                                  <i className={`fa fa-${opt.includes('=true') ? 'check-square' : 'times'}`} /> {opt.replace('=true', '').replace('=false', '')}
   281                              </div>
   282                          ) : (
   283                              <div key={opt} style={{marginRight: '10px'}}>
   284                                  {opt}
   285                              </div>
   286                          )
   287                      )}
   288                  </div>
   289              ),
   290              edit: (formApi: FormApi) => (
   291                  <div>
   292                      <FormField formApi={formApi} field='spec.syncPolicy.syncOptions' component={ApplicationSyncOptionsField} />
   293                  </div>
   294              )
   295          },
   296          {
   297              title: 'RETRY OPTIONS',
   298              view: <ApplicationRetryView initValues={app.spec.syncPolicy ? app.spec.syncPolicy.retry : null} />,
   299              edit: (formApi: FormApi) => (
   300                  <div>
   301                      <ApplicationRetryOptions formApi={formApi} initValues={app.spec.syncPolicy ? app.spec.syncPolicy.retry : null} field='spec.syncPolicy.retry' />
   302                  </div>
   303              )
   304          },
   305          {
   306              title: 'STATUS',
   307              view: (
   308                  <span>
   309                      <ComparisonStatusIcon status={app.status.sync.status} /> {app.status.sync.status} {syncStatusMessage(app)}
   310                  </span>
   311              )
   312          },
   313          {
   314              title: 'HEALTH',
   315              view: (
   316                  <span>
   317                      <HealthStatusIcon state={app.status.health} /> {app.status.health.status}
   318                  </span>
   319              )
   320          },
   321          {
   322              title: 'LINKS',
   323              view: (
   324                  <DataLoader load={() => services.applications.getLinks(app.metadata.name, app.metadata.namespace)} input={app} key='appLinks'>
   325                      {(links: models.LinksResponse) => <DeepLinks links={links.items} />}
   326                  </DataLoader>
   327              )
   328          }
   329      ];
   330      const urls = ExternalLinks(app.status.summary.externalURLs);
   331      if (urls.length > 0) {
   332          attributes.push({
   333              title: 'URLs',
   334              view: (
   335                  <React.Fragment>
   336                      {urls.map((url, i) => {
   337                          return (
   338                              <a key={i} href={url.ref} target='__blank'>
   339                                  {url.title} &nbsp;
   340                              </a>
   341                          );
   342                      })}
   343                  </React.Fragment>
   344              )
   345          });
   346      }
   347  
   348      if ((app.status.summary.images || []).length) {
   349          attributes.push({
   350              title: 'IMAGES',
   351              view: (
   352                  <div className='application-summary__labels'>
   353                      {(app.status.summary.images || []).sort().map(image => (
   354                          <span className='application-summary__label' key={image}>
   355                              {image}
   356                          </span>
   357                      ))}
   358                  </div>
   359              )
   360          });
   361      }
   362  
   363      async function setAutoSync(ctx: ContextApis, confirmationTitle: string, confirmationText: string, prune: boolean, selfHeal: boolean) {
   364          const confirmed = await ctx.popup.confirm(confirmationTitle, confirmationText);
   365          if (confirmed) {
   366              try {
   367                  setChangeSync(true);
   368                  const updatedApp = JSON.parse(JSON.stringify(props.app)) as models.Application;
   369                  if (!updatedApp.spec.syncPolicy) {
   370                      updatedApp.spec.syncPolicy = {};
   371                  }
   372                  updatedApp.spec.syncPolicy.automated = {prune, selfHeal};
   373                  await updateApp(updatedApp, {validate: false});
   374              } catch (e) {
   375                  ctx.notifications.show({
   376                      content: <ErrorNotification title={`Unable to "${confirmationTitle.replace(/\?/g, '')}:`} e={e} />,
   377                      type: NotificationType.Error
   378                  });
   379              } finally {
   380                  setChangeSync(false);
   381              }
   382          }
   383      }
   384  
   385      async function unsetAutoSync(ctx: ContextApis) {
   386          const confirmed = await ctx.popup.confirm('Disable Auto-Sync?', 'Are you sure you want to disable automated application synchronization');
   387          if (confirmed) {
   388              try {
   389                  setChangeSync(true);
   390                  const updatedApp = JSON.parse(JSON.stringify(props.app)) as models.Application;
   391                  updatedApp.spec.syncPolicy.automated = null;
   392                  await updateApp(updatedApp, {validate: false});
   393              } catch (e) {
   394                  ctx.notifications.show({
   395                      content: <ErrorNotification title='Unable to disable Auto-Sync' e={e} />,
   396                      type: NotificationType.Error
   397                  });
   398              } finally {
   399                  setChangeSync(false);
   400              }
   401          }
   402      }
   403  
   404      const items = app.spec.info || [];
   405      const [adjustedCount, setAdjustedCount] = React.useState(0);
   406  
   407      const added = new Array<{name: string; value: string; key: string}>();
   408      for (let i = 0; i < adjustedCount; i++) {
   409          added.push({name: '', value: '', key: (items.length + i).toString()});
   410      }
   411      for (let i = 0; i > adjustedCount; i--) {
   412          items.pop();
   413      }
   414      const allItems = items.concat(added);
   415      const infoItems: EditablePanelItem[] = allItems
   416          .map((info, i) => ({
   417              key: i.toString(),
   418              title: info.name,
   419              view: info.value.match(urlPattern) ? (
   420                  <a href={info.value} target='__blank'>
   421                      {info.value}
   422                  </a>
   423              ) : (
   424                  info.value
   425              ),
   426              titleEdit: (formApi: FormApi) => (
   427                  <React.Fragment>
   428                      {i > 0 && (
   429                          <i
   430                              className='fa fa-sort-up application-summary__sort-icon'
   431                              onClick={() => {
   432                                  formApi.setValue('spec.info', swap(formApi.getFormState().values.spec.info || [], i, i - 1));
   433                              }}
   434                          />
   435                      )}
   436                      <FormField formApi={formApi} field={`spec.info[${[i]}].name`} component={Text} componentProps={{style: {width: '99%'}}} />
   437                      {i < allItems.length - 1 && (
   438                          <i
   439                              className='fa fa-sort-down application-summary__sort-icon'
   440                              onClick={() => {
   441                                  formApi.setValue('spec.info', swap(formApi.getFormState().values.spec.info || [], i, i + 1));
   442                              }}
   443                          />
   444                      )}
   445                  </React.Fragment>
   446              ),
   447              edit: (formApi: FormApi) => (
   448                  <React.Fragment>
   449                      <FormField formApi={formApi} field={`spec.info[${[i]}].value`} component={Text} />
   450                      <i
   451                          className='fa fa-times application-summary__remove-icon'
   452                          onClick={() => {
   453                              const values = (formApi.getFormState().values.spec.info || []) as Array<any>;
   454                              formApi.setValue('spec.info', [...values.slice(0, i), ...values.slice(i + 1, values.length)]);
   455                              setAdjustedCount(adjustedCount - 1);
   456                          }}
   457                      />
   458                  </React.Fragment>
   459              )
   460          }))
   461          .concat({
   462              key: '-1',
   463              title: '',
   464              titleEdit: () => (
   465                  <button
   466                      className='argo-button argo-button--base'
   467                      onClick={() => {
   468                          setAdjustedCount(adjustedCount + 1);
   469                      }}>
   470                      ADD NEW ITEM
   471                  </button>
   472              ),
   473              view: null as any,
   474              edit: null
   475          });
   476  
   477      return (
   478          <div className='application-summary'>
   479              <EditablePanel
   480                  save={updateApp}
   481                  validate={input => ({
   482                      'spec.project': !input.spec.project && 'Project name is required',
   483                      'spec.destination.server': !input.spec.destination.server && input.spec.destination.hasOwnProperty('server') && 'Cluster server is required',
   484                      'spec.destination.name': !input.spec.destination.name && input.spec.destination.hasOwnProperty('name') && 'Cluster name is required'
   485                  })}
   486                  values={app}
   487                  title={app.metadata.name.toLocaleUpperCase()}
   488                  items={attributes}
   489                  onModeSwitch={() => notificationSubscriptions.onResetNotificationSubscriptions()}
   490              />
   491              <Consumer>
   492                  {ctx => (
   493                      <div className='white-box'>
   494                          <div className='white-box__details'>
   495                              <p>SYNC POLICY</p>
   496                              <div className='row white-box__details-row'>
   497                                  <div className='columns small-3'>{(app.spec.syncPolicy && app.spec.syncPolicy.automated && <span>AUTOMATED</span>) || <span>NONE</span>}</div>
   498                                  <div className='columns small-9'>
   499                                      {(app.spec.syncPolicy && app.spec.syncPolicy.automated && (
   500                                          <button className='argo-button argo-button--base' onClick={() => unsetAutoSync(ctx)}>
   501                                              <Spinner show={changeSync} style={{marginRight: '5px'}} />
   502                                              Disable Auto-Sync
   503                                          </button>
   504                                      )) || (
   505                                          <button
   506                                              className='argo-button argo-button--base'
   507                                              onClick={() =>
   508                                                  setAutoSync(ctx, 'Enable Auto-Sync?', 'Are you sure you want to enable automated application synchronization?', false, false)
   509                                              }>
   510                                              <Spinner show={changeSync} style={{marginRight: '5px'}} />
   511                                              Enable Auto-Sync
   512                                          </button>
   513                                      )}
   514                                  </div>
   515                              </div>
   516  
   517                              {app.spec.syncPolicy && app.spec.syncPolicy.automated && (
   518                                  <React.Fragment>
   519                                      <div className='row white-box__details-row'>
   520                                          <div className='columns small-3'>PRUNE RESOURCES</div>
   521                                          <div className='columns small-9'>
   522                                              {(app.spec.syncPolicy.automated.prune && (
   523                                                  <button
   524                                                      className='argo-button argo-button--base'
   525                                                      onClick={() =>
   526                                                          setAutoSync(
   527                                                              ctx,
   528                                                              'Disable Prune Resources?',
   529                                                              'Are you sure you want to disable resource pruning during automated application synchronization?',
   530                                                              false,
   531                                                              app.spec.syncPolicy.automated.selfHeal
   532                                                          )
   533                                                      }>
   534                                                      Disable
   535                                                  </button>
   536                                              )) || (
   537                                                  <button
   538                                                      className='argo-button argo-button--base'
   539                                                      onClick={() =>
   540                                                          setAutoSync(
   541                                                              ctx,
   542                                                              'Enable Prune Resources?',
   543                                                              'Are you sure you want to enable resource pruning during automated application synchronization?',
   544                                                              true,
   545                                                              app.spec.syncPolicy.automated.selfHeal
   546                                                          )
   547                                                      }>
   548                                                      Enable
   549                                                  </button>
   550                                              )}
   551                                          </div>
   552                                      </div>
   553                                      <div className='row white-box__details-row'>
   554                                          <div className='columns small-3'>SELF HEAL</div>
   555                                          <div className='columns small-9'>
   556                                              {(app.spec.syncPolicy.automated.selfHeal && (
   557                                                  <button
   558                                                      className='argo-button argo-button--base'
   559                                                      onClick={() =>
   560                                                          setAutoSync(
   561                                                              ctx,
   562                                                              'Disable Self Heal?',
   563                                                              'Are you sure you want to disable automated self healing?',
   564                                                              app.spec.syncPolicy.automated.prune,
   565                                                              false
   566                                                          )
   567                                                      }>
   568                                                      Disable
   569                                                  </button>
   570                                              )) || (
   571                                                  <button
   572                                                      className='argo-button argo-button--base'
   573                                                      onClick={() =>
   574                                                          setAutoSync(
   575                                                              ctx,
   576                                                              'Enable Self Heal?',
   577                                                              'Are you sure you want to enable automated self healing?',
   578                                                              app.spec.syncPolicy.automated.prune,
   579                                                              true
   580                                                          )
   581                                                      }>
   582                                                      Enable
   583                                                  </button>
   584                                              )}
   585                                          </div>
   586                                      </div>
   587                                  </React.Fragment>
   588                              )}
   589                          </div>
   590                      </div>
   591                  )}
   592              </Consumer>
   593              <BadgePanel app={props.app.metadata.name} appNamespace={props.app.metadata.namespace} nsEnabled={useAuthSettingsCtx?.appsInAnyNamespaceEnabled} />
   594              <EditablePanel
   595                  save={updateApp}
   596                  values={app}
   597                  title='INFO'
   598                  items={infoItems}
   599                  onModeSwitch={() => {
   600                      setAdjustedCount(0);
   601                      notificationSubscriptions.onResetNotificationSubscriptions();
   602                  }}
   603              />
   604          </div>
   605      );
   606  };