github.com/argoproj/argo-cd@v1.8.7/ui/src/app/applications/components/applications-list/applications-list.tsx (about)

     1  import {Autocomplete, ErrorNotification, MockupList, NotificationType, SlidingPanel} from 'argo-ui';
     2  import * as classNames from 'classnames';
     3  import * as minimatch from 'minimatch';
     4  import * as React from 'react';
     5  import {RouteComponentProps} from 'react-router';
     6  import {Observable} from 'rxjs';
     7  
     8  import {ClusterCtx, DataLoader, EmptyState, ObservableQuery, Page, Paginate, Query, Spinner} from '../../../shared/components';
     9  import {Consumer, ContextApis} from '../../../shared/context';
    10  import * as models from '../../../shared/models';
    11  import {AppsListPreferences, AppsListViewType, services} from '../../../shared/services';
    12  import {ApplicationCreatePanel} from '../application-create-panel/application-create-panel';
    13  import {ApplicationSyncPanel} from '../application-sync-panel/application-sync-panel';
    14  import {ApplicationsSyncPanel} from '../applications-sync-panel/applications-sync-panel';
    15  import * as LabelSelector from '../label-selector';
    16  import * as AppUtils from '../utils';
    17  import {ApplicationsFilter} from './applications-filter';
    18  import {ApplicationsSummary} from './applications-summary';
    19  import {ApplicationsTable} from './applications-table';
    20  import {ApplicationTiles} from './applications-tiles';
    21  
    22  require('./applications-list.scss');
    23  
    24  const EVENTS_BUFFER_TIMEOUT = 500;
    25  const WATCH_RETRY_TIMEOUT = 500;
    26  const APP_FIELDS = [
    27      'metadata.name',
    28      'metadata.annotations',
    29      'metadata.labels',
    30      'metadata.creationTimestamp',
    31      'metadata.deletionTimestamp',
    32      'spec',
    33      'operation.sync',
    34      'status.sync.status',
    35      'status.health',
    36      'status.operationState.phase',
    37      'status.operationState.operation.sync',
    38      'status.summary'
    39  ];
    40  const APP_LIST_FIELDS = ['metadata.resourceVersion', ...APP_FIELDS.map(field => `items.${field}`)];
    41  const APP_WATCH_FIELDS = ['result.type', ...APP_FIELDS.map(field => `result.application.${field}`)];
    42  
    43  function loadApplications(): Observable<models.Application[]> {
    44      return Observable.fromPromise(services.applications.list([], {fields: APP_LIST_FIELDS})).flatMap(applicationsList => {
    45          const applications = applicationsList.items;
    46          return Observable.merge(
    47              Observable.from([applications]),
    48              services.applications
    49                  .watch({resourceVersion: applicationsList.metadata.resourceVersion}, {fields: APP_WATCH_FIELDS})
    50                  .repeat()
    51                  .retryWhen(errors => errors.delay(WATCH_RETRY_TIMEOUT))
    52                  // batch events to avoid constant re-rendering and improve UI performance
    53                  .bufferTime(EVENTS_BUFFER_TIMEOUT)
    54                  .map(appChanges => {
    55                      appChanges.forEach(appChange => {
    56                          const index = applications.findIndex(item => item.metadata.name === appChange.application.metadata.name);
    57                          switch (appChange.type) {
    58                              case 'DELETED':
    59                                  if (index > -1) {
    60                                      applications.splice(index, 1);
    61                                  }
    62                                  break;
    63                              default:
    64                                  if (index > -1) {
    65                                      applications[index] = appChange.application;
    66                                  } else {
    67                                      applications.unshift(appChange.application);
    68                                  }
    69                                  break;
    70                          }
    71                      });
    72                      return {applications, updated: appChanges.length > 0};
    73                  })
    74                  .filter(item => item.updated)
    75                  .map(item => item.applications)
    76          );
    77      });
    78  }
    79  
    80  const ViewPref = ({children}: {children: (pref: AppsListPreferences & {page: number; search: string}) => React.ReactNode}) => (
    81      <ObservableQuery>
    82          {q => (
    83              <DataLoader
    84                  load={() =>
    85                      Observable.combineLatest(services.viewPreferences.getPreferences().map(item => item.appList), q).map(items => {
    86                          const params = items[1];
    87                          const viewPref: AppsListPreferences = {...items[0]};
    88                          if (params.get('proj') != null) {
    89                              viewPref.projectsFilter = params
    90                                  .get('proj')
    91                                  .split(',')
    92                                  .filter(item => !!item);
    93                          }
    94                          if (params.get('sync') != null) {
    95                              viewPref.syncFilter = params
    96                                  .get('sync')
    97                                  .split(',')
    98                                  .filter(item => !!item);
    99                          }
   100                          if (params.get('health') != null) {
   101                              viewPref.healthFilter = params
   102                                  .get('health')
   103                                  .split(',')
   104                                  .filter(item => !!item);
   105                          }
   106                          if (params.get('namespace') != null) {
   107                              viewPref.namespacesFilter = params
   108                                  .get('namespace')
   109                                  .split(',')
   110                                  .filter(item => !!item);
   111                          }
   112                          if (params.get('cluster') != null) {
   113                              viewPref.clustersFilter = params
   114                                  .get('cluster')
   115                                  .split(',')
   116                                  .filter(item => !!item);
   117                          }
   118                          if (params.get('view') != null) {
   119                              viewPref.view = params.get('view') as AppsListViewType;
   120                          }
   121                          if (params.get('labels') != null) {
   122                              viewPref.labelsFilter = params
   123                                  .get('labels')
   124                                  .split(',')
   125                                  .map(decodeURIComponent)
   126                                  .filter(item => !!item);
   127                          }
   128                          return {...viewPref, page: parseInt(params.get('page') || '0', 10), search: params.get('search') || ''};
   129                      })
   130                  }>
   131                  {pref => children(pref)}
   132              </DataLoader>
   133          )}
   134      </ObservableQuery>
   135  );
   136  
   137  function filterApps(applications: models.Application[], pref: AppsListPreferences, search: string) {
   138      return applications.filter(
   139          app =>
   140              (search === '' || app.metadata.name.includes(search)) &&
   141              (pref.projectsFilter.length === 0 || pref.projectsFilter.includes(app.spec.project)) &&
   142              (pref.reposFilter.length === 0 || pref.reposFilter.includes(app.spec.source.repoURL)) &&
   143              (pref.syncFilter.length === 0 || pref.syncFilter.includes(app.status.sync.status)) &&
   144              (pref.healthFilter.length === 0 || pref.healthFilter.includes(app.status.health.status)) &&
   145              (pref.namespacesFilter.length === 0 || pref.namespacesFilter.some(ns => app.spec.destination.namespace && minimatch(app.spec.destination.namespace, ns))) &&
   146              (pref.clustersFilter.length === 0 || pref.clustersFilter.some(server => server.includes(app.spec.destination.server || app.spec.destination.name))) &&
   147              (pref.labelsFilter.length === 0 || pref.labelsFilter.every(selector => LabelSelector.match(selector, app.metadata.labels)))
   148      );
   149  }
   150  
   151  function tryJsonParse(input: string) {
   152      try {
   153          return (input && JSON.parse(input)) || null;
   154      } catch {
   155          return null;
   156      }
   157  }
   158  
   159  export const ApplicationsList = (props: RouteComponentProps<{}>) => {
   160      const query = new URLSearchParams(props.location.search);
   161      const appInput = tryJsonParse(query.get('new'));
   162      const syncAppsInput = tryJsonParse(query.get('syncApps'));
   163      const [createApi, setCreateApi] = React.useState(null);
   164      const clusters = React.useMemo(() => services.clusters.list(), []);
   165      const [isAppCreatePending, setAppCreatePending] = React.useState(false);
   166  
   167      const loaderRef = React.useRef<DataLoader>();
   168      function refreshApp(appName: string) {
   169          // app refreshing might be done too quickly so that UI might miss it due to event batching
   170          // add refreshing annotation in the UI to improve user experience
   171          if (loaderRef.current) {
   172              const applications = loaderRef.current.getData() as models.Application[];
   173              const app = applications.find(item => item.metadata.name === appName);
   174              if (app) {
   175                  AppUtils.setAppRefreshing(app);
   176                  loaderRef.current.setData(applications);
   177              }
   178          }
   179          services.applications.get(appName, 'normal');
   180      }
   181  
   182      function onFilterPrefChanged(ctx: ContextApis, newPref: AppsListPreferences) {
   183          services.viewPreferences.updatePreferences({appList: newPref});
   184          ctx.navigation.goto('.', {
   185              proj: newPref.projectsFilter.join(','),
   186              sync: newPref.syncFilter.join(','),
   187              health: newPref.healthFilter.join(','),
   188              namespace: newPref.namespacesFilter.join(','),
   189              cluster: newPref.clustersFilter.join(','),
   190              labels: newPref.labelsFilter.map(encodeURIComponent).join(',')
   191          });
   192      }
   193  
   194      return (
   195          <ClusterCtx.Provider value={clusters}>
   196              <Consumer>
   197                  {ctx => (
   198                      <Page
   199                          title='Applications'
   200                          toolbar={services.viewPreferences.getPreferences().map(pref => ({
   201                              breadcrumbs: [{title: 'Applications', path: '/applications'}],
   202                              tools: (
   203                                  <React.Fragment key='app-list-tools'>
   204                                      <span className='applications-list__view-type'>
   205                                          <i
   206                                              className={classNames('fa fa-th', {selected: pref.appList.view === 'tiles'})}
   207                                              title='Tiles'
   208                                              onClick={() => {
   209                                                  ctx.navigation.goto('.', {view: 'tiles'});
   210                                                  services.viewPreferences.updatePreferences({appList: {...pref.appList, view: 'tiles'}});
   211                                              }}
   212                                          />
   213                                          <i
   214                                              className={classNames('fa fa-th-list', {selected: pref.appList.view === 'list'})}
   215                                              title='List'
   216                                              onClick={() => {
   217                                                  ctx.navigation.goto('.', {view: 'list'});
   218                                                  services.viewPreferences.updatePreferences({appList: {...pref.appList, view: 'list'}});
   219                                              }}
   220                                          />
   221                                          <i
   222                                              className={classNames('fa fa-chart-pie', {selected: pref.appList.view === 'summary'})}
   223                                              title='Summary'
   224                                              onClick={() => {
   225                                                  ctx.navigation.goto('.', {view: 'summary'});
   226                                                  services.viewPreferences.updatePreferences({appList: {...pref.appList, view: 'summary'}});
   227                                              }}
   228                                          />
   229                                      </span>
   230                                  </React.Fragment>
   231                              ),
   232                              actionMenu: {
   233                                  items: [
   234                                      {
   235                                          title: 'New App',
   236                                          iconClassName: 'fa fa-plus',
   237                                          qeId: 'applications-list-button-new-app',
   238                                          action: () => ctx.navigation.goto('.', {new: '{}'})
   239                                      },
   240                                      {
   241                                          title: 'Sync Apps',
   242                                          iconClassName: 'fa fa-sync',
   243                                          action: () => ctx.navigation.goto('.', {syncApps: true})
   244                                      }
   245                                  ]
   246                              }
   247                          }))}>
   248                          <div className='applications-list'>
   249                              <ViewPref>
   250                                  {pref => (
   251                                      <DataLoader
   252                                          ref={loaderRef}
   253                                          load={() => AppUtils.handlePageVisibility(() => loadApplications())}
   254                                          loadingRenderer={() => (
   255                                              <div className='argo-container'>
   256                                                  <MockupList height={100} marginTop={30} />
   257                                              </div>
   258                                          )}>
   259                                          {(applications: models.Application[]) =>
   260                                              applications.length === 0 && (pref.labelsFilter || []).length === 0 ? (
   261                                                  <EmptyState icon='argo-icon-application'>
   262                                                      <h4>No applications yet</h4>
   263                                                      <h5>Create new application to start managing resources in your cluster</h5>
   264                                                      <button
   265                                                          qe-id='applications-list-button-create-application'
   266                                                          className='argo-button argo-button--base'
   267                                                          onClick={() => ctx.navigation.goto('.', {new: JSON.stringify({})})}>
   268                                                          Create application
   269                                                      </button>
   270                                                  </EmptyState>
   271                                              ) : (
   272                                                  <div className='row'>
   273                                                      <div className='columns small-12 xxlarge-2'>
   274                                                          <Query>
   275                                                              {q => (
   276                                                                  <div className='applications-list__search'>
   277                                                                      <i className='fa fa-search' />
   278                                                                      {q.get('search') && (
   279                                                                          <i className='fa fa-times' onClick={() => ctx.navigation.goto('.', {search: null}, {replace: true})} />
   280                                                                      )}
   281                                                                      <Autocomplete
   282                                                                          filterSuggestions={true}
   283                                                                          renderInput={inputProps => (
   284                                                                              <input
   285                                                                                  {...inputProps}
   286                                                                                  onFocus={e => {
   287                                                                                      e.target.select();
   288                                                                                      if (inputProps.onFocus) {
   289                                                                                          inputProps.onFocus(e);
   290                                                                                      }
   291                                                                                  }}
   292                                                                                  className='argo-field'
   293                                                                                  placeholder='Search applications...'
   294                                                                              />
   295                                                                          )}
   296                                                                          renderItem={item => (
   297                                                                              <React.Fragment>
   298                                                                                  <i className='icon argo-icon-application' /> {item.label}
   299                                                                              </React.Fragment>
   300                                                                          )}
   301                                                                          onSelect={val => {
   302                                                                              ctx.navigation.goto(`./${val}`);
   303                                                                          }}
   304                                                                          onChange={e => ctx.navigation.goto('.', {search: e.target.value}, {replace: true})}
   305                                                                          value={q.get('search') || ''}
   306                                                                          items={applications.map(app => app.metadata.name)}
   307                                                                      />
   308                                                                  </div>
   309                                                              )}
   310                                                          </Query>
   311                                                          <DataLoader load={() => services.clusters.list()}>
   312                                                              {clusterList => {
   313                                                                  return (
   314                                                                      <ApplicationsFilter
   315                                                                          clusters={clusterList}
   316                                                                          applications={applications}
   317                                                                          pref={pref}
   318                                                                          onChange={newPref => onFilterPrefChanged(ctx, newPref)}
   319                                                                      />
   320                                                                  );
   321                                                              }}
   322                                                          </DataLoader>
   323  
   324                                                          {syncAppsInput && (
   325                                                              <ApplicationsSyncPanel
   326                                                                  key='syncsPanel'
   327                                                                  show={syncAppsInput}
   328                                                                  hide={() => ctx.navigation.goto('.', {syncApps: null})}
   329                                                                  apps={filterApps(applications, pref, pref.search)}
   330                                                              />
   331                                                          )}
   332                                                      </div>
   333                                                      <div className='columns small-12 xxlarge-10'>
   334                                                          {(pref.view === 'summary' && <ApplicationsSummary applications={filterApps(applications, pref, pref.search)} />) || (
   335                                                              <Paginate
   336                                                                  preferencesKey='applications-list'
   337                                                                  page={pref.page}
   338                                                                  emptyState={() => (
   339                                                                      <EmptyState icon='fa fa-search'>
   340                                                                          <h4>No matching applications found</h4>
   341                                                                          <h5>
   342                                                                              Change filter criteria or&nbsp;
   343                                                                              <a
   344                                                                                  onClick={() => {
   345                                                                                      AppsListPreferences.clearFilters(pref);
   346                                                                                      onFilterPrefChanged(ctx, pref);
   347                                                                                  }}>
   348                                                                                  clear filters
   349                                                                              </a>
   350                                                                          </h5>
   351                                                                      </EmptyState>
   352                                                                  )}
   353                                                                  data={filterApps(applications, pref, pref.search)}
   354                                                                  onPageChange={page => ctx.navigation.goto('.', {page})}>
   355                                                                  {data =>
   356                                                                      (pref.view === 'tiles' && (
   357                                                                          <ApplicationTiles
   358                                                                              applications={data}
   359                                                                              syncApplication={appName => ctx.navigation.goto('.', {syncApp: appName})}
   360                                                                              refreshApplication={refreshApp}
   361                                                                              deleteApplication={appName => AppUtils.deleteApplication(appName, ctx)}
   362                                                                          />
   363                                                                      )) || (
   364                                                                          <ApplicationsTable
   365                                                                              applications={data}
   366                                                                              syncApplication={appName => ctx.navigation.goto('.', {syncApp: appName})}
   367                                                                              refreshApplication={refreshApp}
   368                                                                              deleteApplication={appName => AppUtils.deleteApplication(appName, ctx)}
   369                                                                          />
   370                                                                      )
   371                                                                  }
   372                                                              </Paginate>
   373                                                          )}
   374                                                      </div>
   375                                                  </div>
   376                                              )
   377                                          }
   378                                      </DataLoader>
   379                                  )}
   380                              </ViewPref>
   381                          </div>
   382                          <ObservableQuery>
   383                              {q => (
   384                                  <DataLoader
   385                                      load={() =>
   386                                          q.flatMap(params => {
   387                                              const syncApp = params.get('syncApp');
   388                                              return (syncApp && Observable.fromPromise(services.applications.get(syncApp))) || Observable.from([null]);
   389                                          })
   390                                      }>
   391                                      {app => (
   392                                          <ApplicationSyncPanel key='syncPanel' application={app} selectedResource={'all'} hide={() => ctx.navigation.goto('.', {syncApp: null})} />
   393                                      )}
   394                                  </DataLoader>
   395                              )}
   396                          </ObservableQuery>
   397                          <SlidingPanel
   398                              isShown={!!appInput}
   399                              onClose={() => ctx.navigation.goto('.', {new: null})}
   400                              header={
   401                                  <div>
   402                                      <button
   403                                          qe-id='applications-list-button-create'
   404                                          className='argo-button argo-button--base'
   405                                          disabled={isAppCreatePending}
   406                                          onClick={() => createApi && createApi.submitForm(null)}>
   407                                          <Spinner show={isAppCreatePending} style={{marginRight: '5px'}} />
   408                                          Create
   409                                      </button>{' '}
   410                                      <button onClick={() => ctx.navigation.goto('.', {new: null})} className='argo-button argo-button--base-o'>
   411                                          Cancel
   412                                      </button>
   413                                  </div>
   414                              }>
   415                              {appInput && (
   416                                  <ApplicationCreatePanel
   417                                      getFormApi={api => {
   418                                          setCreateApi(api);
   419                                      }}
   420                                      createApp={async app => {
   421                                          setAppCreatePending(true);
   422                                          try {
   423                                              await services.applications.create(app);
   424                                              ctx.navigation.goto('.', {new: null});
   425                                          } catch (e) {
   426                                              ctx.notifications.show({
   427                                                  content: <ErrorNotification title='Unable to create application' e={e} />,
   428                                                  type: NotificationType.Error
   429                                              });
   430                                          } finally {
   431                                              setAppCreatePending(false);
   432                                          }
   433                                      }}
   434                                      app={appInput}
   435                                      onAppChanged={app => ctx.navigation.goto('.', {new: JSON.stringify(app)}, {replace: true})}
   436                                  />
   437                              )}
   438                          </SlidingPanel>
   439                      </Page>
   440                  )}
   441              </Consumer>
   442          </ClusterCtx.Provider>
   443      );
   444  };