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 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 };