github.com/argoproj/argo-cd/v2@v2.10.9/ui/src/app/applications/components/applications-list/applications-list.tsx (about) 1 import {Autocomplete, ErrorNotification, MockupList, NotificationType, SlidingPanel, Toolbar, Tooltip} from 'argo-ui'; 2 import * as classNames from 'classnames'; 3 import * as React from 'react'; 4 import * as ReactDOM from 'react-dom'; 5 import {Key, KeybindingContext, KeybindingProvider} from 'argo-ui/v2'; 6 import {RouteComponentProps} from 'react-router'; 7 import {combineLatest, from, merge, Observable} from 'rxjs'; 8 import {bufferTime, delay, filter, map, mergeMap, repeat, retryWhen} from 'rxjs/operators'; 9 import {AddAuthToToolbar, ClusterCtx, DataLoader, EmptyState, ObservableQuery, Page, Paginate, Query, Spinner} from '../../../shared/components'; 10 import {AuthSettingsCtx, Consumer, Context, ContextApis} from '../../../shared/context'; 11 import * as models from '../../../shared/models'; 12 import {AppsListViewKey, AppsListPreferences, AppsListViewType, HealthStatusBarPreferences, services} from '../../../shared/services'; 13 import {ApplicationCreatePanel} from '../application-create-panel/application-create-panel'; 14 import {ApplicationSyncPanel} from '../application-sync-panel/application-sync-panel'; 15 import {ApplicationsSyncPanel} from '../applications-sync-panel/applications-sync-panel'; 16 import * as AppUtils from '../utils'; 17 import {ApplicationsFilter, FilteredApp, getFilterResults} from './applications-filter'; 18 import {ApplicationsStatusBar} from './applications-status-bar'; 19 import {ApplicationsSummary} from './applications-summary'; 20 import {ApplicationsTable} from './applications-table'; 21 import {ApplicationTiles} from './applications-tiles'; 22 import {ApplicationsRefreshPanel} from '../applications-refresh-panel/applications-refresh-panel'; 23 import {useSidebarTarget} from '../../../sidebar/sidebar'; 24 25 import './applications-list.scss'; 26 import './flex-top-bar.scss'; 27 28 const EVENTS_BUFFER_TIMEOUT = 500; 29 const WATCH_RETRY_TIMEOUT = 500; 30 31 // The applications list/watch API supports only selected set of fields. 32 // Make sure to register any new fields in the `appFields` map of `pkg/apiclient/application/forwarder_overwrite.go`. 33 const APP_FIELDS = [ 34 'metadata.name', 35 'metadata.namespace', 36 'metadata.annotations', 37 'metadata.labels', 38 'metadata.creationTimestamp', 39 'metadata.deletionTimestamp', 40 'spec', 41 'operation.sync', 42 'status.sync.status', 43 'status.sync.revision', 44 'status.health', 45 'status.operationState.phase', 46 'status.operationState.finishedAt', 47 'status.operationState.operation.sync', 48 'status.summary', 49 'status.resources' 50 ]; 51 const APP_LIST_FIELDS = ['metadata.resourceVersion', ...APP_FIELDS.map(field => `items.${field}`)]; 52 const APP_WATCH_FIELDS = ['result.type', ...APP_FIELDS.map(field => `result.application.${field}`)]; 53 54 function loadApplications(projects: string[], appNamespace: string): Observable<models.Application[]> { 55 return from(services.applications.list(projects, {appNamespace, fields: APP_LIST_FIELDS})).pipe( 56 mergeMap(applicationsList => { 57 const applications = applicationsList.items; 58 return merge( 59 from([applications]), 60 services.applications 61 .watch({projects, resourceVersion: applicationsList.metadata.resourceVersion}, {fields: APP_WATCH_FIELDS}) 62 .pipe(repeat()) 63 .pipe(retryWhen(errors => errors.pipe(delay(WATCH_RETRY_TIMEOUT)))) 64 // batch events to avoid constant re-rendering and improve UI performance 65 .pipe(bufferTime(EVENTS_BUFFER_TIMEOUT)) 66 .pipe( 67 map(appChanges => { 68 appChanges.forEach(appChange => { 69 const index = applications.findIndex(item => AppUtils.appInstanceName(item) === AppUtils.appInstanceName(appChange.application)); 70 switch (appChange.type) { 71 case 'DELETED': 72 if (index > -1) { 73 applications.splice(index, 1); 74 } 75 break; 76 default: 77 if (index > -1) { 78 applications[index] = appChange.application; 79 } else { 80 applications.unshift(appChange.application); 81 } 82 break; 83 } 84 }); 85 return {applications, updated: appChanges.length > 0}; 86 }) 87 ) 88 .pipe(filter(item => item.updated)) 89 .pipe(map(item => item.applications)) 90 ); 91 }) 92 ); 93 } 94 95 const ViewPref = ({children}: {children: (pref: AppsListPreferences & {page: number; search: string}) => React.ReactNode}) => ( 96 <ObservableQuery> 97 {q => ( 98 <DataLoader 99 load={() => 100 combineLatest([services.viewPreferences.getPreferences().pipe(map(item => item.appList)), q]).pipe( 101 map(items => { 102 const params = items[1]; 103 const viewPref: AppsListPreferences = {...items[0]}; 104 if (params.get('proj') != null) { 105 viewPref.projectsFilter = params 106 .get('proj') 107 .split(',') 108 .filter(item => !!item); 109 } 110 if (params.get('sync') != null) { 111 viewPref.syncFilter = params 112 .get('sync') 113 .split(',') 114 .filter(item => !!item); 115 } 116 if (params.get('autoSync') != null) { 117 viewPref.autoSyncFilter = params 118 .get('autoSync') 119 .split(',') 120 .filter(item => !!item); 121 } 122 if (params.get('health') != null) { 123 viewPref.healthFilter = params 124 .get('health') 125 .split(',') 126 .filter(item => !!item); 127 } 128 if (params.get('namespace') != null) { 129 viewPref.namespacesFilter = params 130 .get('namespace') 131 .split(',') 132 .filter(item => !!item); 133 } 134 if (params.get('cluster') != null) { 135 viewPref.clustersFilter = params 136 .get('cluster') 137 .split(',') 138 .filter(item => !!item); 139 } 140 if (params.get('showFavorites') != null) { 141 viewPref.showFavorites = params.get('showFavorites') === 'true'; 142 } 143 if (params.get('view') != null) { 144 viewPref.view = params.get('view') as AppsListViewType; 145 } 146 if (params.get('labels') != null) { 147 viewPref.labelsFilter = params 148 .get('labels') 149 .split(',') 150 .map(decodeURIComponent) 151 .filter(item => !!item); 152 } 153 return {...viewPref, page: parseInt(params.get('page') || '0', 10), search: params.get('search') || ''}; 154 }) 155 ) 156 }> 157 {pref => children(pref)} 158 </DataLoader> 159 )} 160 </ObservableQuery> 161 ); 162 163 function filterApps(applications: models.Application[], pref: AppsListPreferences, search: string): {filteredApps: models.Application[]; filterResults: FilteredApp[]} { 164 applications = applications.map(app => { 165 let isAppOfAppsPattern = false; 166 for (const resource of app.status.resources) { 167 if (resource.kind === 'Application') { 168 isAppOfAppsPattern = true; 169 break; 170 } 171 } 172 return {...app, isAppOfAppsPattern}; 173 }); 174 const filterResults = getFilterResults(applications, pref); 175 return { 176 filterResults, 177 filteredApps: filterResults.filter( 178 app => (search === '' || app.metadata.name.includes(search) || app.metadata.namespace.includes(search)) && Object.values(app.filterResult).every(val => val) 179 ) 180 }; 181 } 182 183 function tryJsonParse(input: string) { 184 try { 185 return (input && JSON.parse(input)) || null; 186 } catch { 187 return null; 188 } 189 } 190 191 const SearchBar = (props: {content: string; ctx: ContextApis; apps: models.Application[]}) => { 192 const {content, ctx, apps} = {...props}; 193 194 const searchBar = React.useRef<HTMLDivElement>(null); 195 196 const query = new URLSearchParams(window.location.search); 197 const appInput = tryJsonParse(query.get('new')); 198 199 const {useKeybinding} = React.useContext(KeybindingContext); 200 const [isFocused, setFocus] = React.useState(false); 201 const useAuthSettingsCtx = React.useContext(AuthSettingsCtx); 202 203 useKeybinding({ 204 keys: Key.SLASH, 205 action: () => { 206 if (searchBar.current && !appInput) { 207 searchBar.current.querySelector('input').focus(); 208 setFocus(true); 209 return true; 210 } 211 return false; 212 } 213 }); 214 215 useKeybinding({ 216 keys: Key.ESCAPE, 217 action: () => { 218 if (searchBar.current && !appInput && isFocused) { 219 searchBar.current.querySelector('input').blur(); 220 setFocus(false); 221 return true; 222 } 223 return false; 224 } 225 }); 226 227 return ( 228 <Autocomplete 229 filterSuggestions={true} 230 renderInput={inputProps => ( 231 <div className='applications-list__search' ref={searchBar}> 232 <i 233 className='fa fa-search' 234 style={{marginRight: '9px', cursor: 'pointer'}} 235 onClick={() => { 236 if (searchBar.current) { 237 searchBar.current.querySelector('input').focus(); 238 } 239 }} 240 /> 241 <input 242 {...inputProps} 243 onFocus={e => { 244 e.target.select(); 245 if (inputProps.onFocus) { 246 inputProps.onFocus(e); 247 } 248 }} 249 style={{fontSize: '14px'}} 250 className='argo-field' 251 placeholder='Search applications...' 252 /> 253 <div className='keyboard-hint'>/</div> 254 {content && ( 255 <i className='fa fa-times' onClick={() => ctx.navigation.goto('.', {search: null}, {replace: true})} style={{cursor: 'pointer', marginLeft: '5px'}} /> 256 )} 257 </div> 258 )} 259 wrapperProps={{className: 'applications-list__search-wrapper'}} 260 renderItem={item => ( 261 <React.Fragment> 262 <i className='icon argo-icon-application' /> {item.label} 263 </React.Fragment> 264 )} 265 onSelect={val => { 266 ctx.navigation.goto(`./${val}`); 267 }} 268 onChange={e => ctx.navigation.goto('.', {search: e.target.value}, {replace: true})} 269 value={content || ''} 270 items={apps.map(app => AppUtils.appQualifiedName(app, useAuthSettingsCtx?.appsInAnyNamespaceEnabled))} 271 /> 272 ); 273 }; 274 275 const FlexTopBar = (props: {toolbar: Toolbar | Observable<Toolbar>}) => { 276 const ctx = React.useContext(Context); 277 const loadToolbar = AddAuthToToolbar(props.toolbar, ctx); 278 return ( 279 <React.Fragment> 280 <div className='top-bar row flex-top-bar' key='tool-bar'> 281 <DataLoader load={() => loadToolbar}> 282 {toolbar => ( 283 <React.Fragment> 284 <div className='flex-top-bar__actions'> 285 {toolbar.actionMenu && ( 286 <React.Fragment> 287 {toolbar.actionMenu.items.map((item, i) => ( 288 <button 289 disabled={!!item.disabled} 290 qe-id={item.qeId} 291 className='argo-button argo-button--base' 292 onClick={() => item.action()} 293 style={{marginRight: 2}} 294 key={i}> 295 {item.iconClassName && <i className={item.iconClassName} style={{marginLeft: '-5px', marginRight: '5px'}} />} 296 <span className='show-for-large'>{item.title}</span> 297 </button> 298 ))} 299 </React.Fragment> 300 )} 301 </div> 302 <div className='flex-top-bar__tools'>{toolbar.tools}</div> 303 </React.Fragment> 304 )} 305 </DataLoader> 306 </div> 307 <div className='flex-top-bar__padder' /> 308 </React.Fragment> 309 ); 310 }; 311 312 export const ApplicationsList = (props: RouteComponentProps<{}>) => { 313 const query = new URLSearchParams(props.location.search); 314 const appInput = tryJsonParse(query.get('new')); 315 const syncAppsInput = tryJsonParse(query.get('syncApps')); 316 const refreshAppsInput = tryJsonParse(query.get('refreshApps')); 317 const [createApi, setCreateApi] = React.useState(null); 318 const clusters = React.useMemo(() => services.clusters.list(), []); 319 const [isAppCreatePending, setAppCreatePending] = React.useState(false); 320 const loaderRef = React.useRef<DataLoader>(); 321 const {List, Summary, Tiles} = AppsListViewKey; 322 323 function refreshApp(appName: string, appNamespace: string) { 324 // app refreshing might be done too quickly so that UI might miss it due to event batching 325 // add refreshing annotation in the UI to improve user experience 326 if (loaderRef.current) { 327 const applications = loaderRef.current.getData() as models.Application[]; 328 const app = applications.find(item => item.metadata.name === appName && item.metadata.namespace === appNamespace); 329 if (app) { 330 AppUtils.setAppRefreshing(app); 331 loaderRef.current.setData(applications); 332 } 333 } 334 services.applications.get(appName, appNamespace, 'normal'); 335 } 336 337 function onFilterPrefChanged(ctx: ContextApis, newPref: AppsListPreferences) { 338 services.viewPreferences.updatePreferences({appList: newPref}); 339 ctx.navigation.goto( 340 '.', 341 { 342 proj: newPref.projectsFilter.join(','), 343 sync: newPref.syncFilter.join(','), 344 autoSync: newPref.autoSyncFilter.join(','), 345 health: newPref.healthFilter.join(','), 346 namespace: newPref.namespacesFilter.join(','), 347 cluster: newPref.clustersFilter.join(','), 348 labels: newPref.labelsFilter.map(encodeURIComponent).join(',') 349 }, 350 {replace: true} 351 ); 352 } 353 354 function getPageTitle(view: string) { 355 switch (view) { 356 case List: 357 return 'Applications List'; 358 case Tiles: 359 return 'Applications Tiles'; 360 case Summary: 361 return 'Applications Summary'; 362 } 363 return ''; 364 } 365 366 const sidebarTarget = useSidebarTarget(); 367 368 return ( 369 <ClusterCtx.Provider value={clusters}> 370 <KeybindingProvider> 371 <Consumer> 372 {ctx => ( 373 <ViewPref> 374 {pref => ( 375 <Page 376 key={pref.view} 377 title={getPageTitle(pref.view)} 378 useTitleOnly={true} 379 toolbar={{breadcrumbs: [{title: 'Applications', path: '/applications'}]}} 380 hideAuth={true}> 381 <DataLoader 382 input={pref.projectsFilter?.join(',')} 383 ref={loaderRef} 384 load={() => AppUtils.handlePageVisibility(() => loadApplications(pref.projectsFilter, query.get('appNamespace')))} 385 loadingRenderer={() => ( 386 <div className='argo-container'> 387 <MockupList height={100} marginTop={30} /> 388 </div> 389 )}> 390 {(applications: models.Application[]) => { 391 const healthBarPrefs = pref.statusBarView || ({} as HealthStatusBarPreferences); 392 const {filteredApps, filterResults} = filterApps(applications, pref, pref.search); 393 return ( 394 <React.Fragment> 395 <FlexTopBar 396 toolbar={{ 397 tools: ( 398 <React.Fragment key='app-list-tools'> 399 <Query>{q => <SearchBar content={q.get('search')} apps={applications} ctx={ctx} />}</Query> 400 <Tooltip content='Toggle Health Status Bar'> 401 <button 402 className={`applications-list__accordion argo-button argo-button--base${ 403 healthBarPrefs.showHealthStatusBar ? '-o' : '' 404 }`} 405 style={{border: 'none'}} 406 onClick={() => { 407 healthBarPrefs.showHealthStatusBar = !healthBarPrefs.showHealthStatusBar; 408 services.viewPreferences.updatePreferences({ 409 appList: { 410 ...pref, 411 statusBarView: { 412 ...healthBarPrefs, 413 showHealthStatusBar: healthBarPrefs.showHealthStatusBar 414 } 415 } 416 }); 417 }}> 418 <i className={`fas fa-ruler-horizontal`} /> 419 </button> 420 </Tooltip> 421 <div className='applications-list__view-type' style={{marginLeft: 'auto'}}> 422 <i 423 className={classNames('fa fa-th', {selected: pref.view === Tiles}, 'menu_icon')} 424 title='Tiles' 425 onClick={() => { 426 ctx.navigation.goto('.', {view: Tiles}); 427 services.viewPreferences.updatePreferences({appList: {...pref, view: Tiles}}); 428 }} 429 /> 430 <i 431 className={classNames('fa fa-th-list', {selected: pref.view === List}, 'menu_icon')} 432 title='List' 433 onClick={() => { 434 ctx.navigation.goto('.', {view: List}); 435 services.viewPreferences.updatePreferences({appList: {...pref, view: List}}); 436 }} 437 /> 438 <i 439 className={classNames('fa fa-chart-pie', {selected: pref.view === Summary}, 'menu_icon')} 440 title='Summary' 441 onClick={() => { 442 ctx.navigation.goto('.', {view: Summary}); 443 services.viewPreferences.updatePreferences({appList: {...pref, view: Summary}}); 444 }} 445 /> 446 </div> 447 </React.Fragment> 448 ), 449 actionMenu: { 450 items: [ 451 { 452 title: 'New App', 453 iconClassName: 'fa fa-plus', 454 qeId: 'applications-list-button-new-app', 455 action: () => ctx.navigation.goto('.', {new: '{}'}, {replace: true}) 456 }, 457 { 458 title: 'Sync Apps', 459 iconClassName: 'fa fa-sync', 460 action: () => ctx.navigation.goto('.', {syncApps: true}, {replace: true}) 461 }, 462 { 463 title: 'Refresh Apps', 464 iconClassName: 'fa fa-redo', 465 action: () => ctx.navigation.goto('.', {refreshApps: true}, {replace: true}) 466 } 467 ] 468 } 469 }} 470 /> 471 <div className='applications-list'> 472 {applications.length === 0 && pref.projectsFilter?.length === 0 && (pref.labelsFilter || []).length === 0 ? ( 473 <EmptyState icon='argo-icon-application'> 474 <h4>No applications available to you just yet</h4> 475 <h5>Create new application to start managing resources in your cluster</h5> 476 <button 477 qe-id='applications-list-button-create-application' 478 className='argo-button argo-button--base' 479 onClick={() => ctx.navigation.goto('.', {new: JSON.stringify({})}, {replace: true})}> 480 Create application 481 </button> 482 </EmptyState> 483 ) : ( 484 <> 485 {ReactDOM.createPortal( 486 <DataLoader load={() => services.viewPreferences.getPreferences()}> 487 {allpref => ( 488 <ApplicationsFilter 489 apps={filterResults} 490 onChange={newPrefs => onFilterPrefChanged(ctx, newPrefs)} 491 pref={pref} 492 collapsed={allpref.hideSidebar} 493 /> 494 )} 495 </DataLoader>, 496 sidebarTarget?.current 497 )} 498 499 {(pref.view === 'summary' && <ApplicationsSummary applications={filteredApps} />) || ( 500 <Paginate 501 header={filteredApps.length > 1 && <ApplicationsStatusBar applications={filteredApps} />} 502 showHeader={healthBarPrefs.showHealthStatusBar} 503 preferencesKey='applications-list' 504 page={pref.page} 505 emptyState={() => ( 506 <EmptyState icon='fa fa-search'> 507 <h4>No matching applications found</h4> 508 <h5> 509 Change filter criteria or 510 <a 511 onClick={() => { 512 AppsListPreferences.clearFilters(pref); 513 onFilterPrefChanged(ctx, pref); 514 }}> 515 clear filters 516 </a> 517 </h5> 518 </EmptyState> 519 )} 520 sortOptions={[ 521 {title: 'Name', compare: (a, b) => a.metadata.name.localeCompare(b.metadata.name)}, 522 { 523 title: 'Created At', 524 compare: (b, a) => a.metadata.creationTimestamp.localeCompare(b.metadata.creationTimestamp) 525 }, 526 { 527 title: 'Synchronized', 528 compare: (b, a) => 529 a.status.operationState?.finishedAt?.localeCompare(b.status.operationState?.finishedAt) 530 } 531 ]} 532 data={filteredApps} 533 onPageChange={page => ctx.navigation.goto('.', {page})}> 534 {data => 535 (pref.view === 'tiles' && ( 536 <ApplicationTiles 537 applications={data} 538 syncApplication={(appName, appNamespace) => 539 ctx.navigation.goto('.', {syncApp: appName, appNamespace}, {replace: true}) 540 } 541 refreshApplication={refreshApp} 542 deleteApplication={(appName, appNamespace) => 543 AppUtils.deleteApplication(appName, appNamespace, ctx) 544 } 545 /> 546 )) || ( 547 <ApplicationsTable 548 applications={data} 549 syncApplication={(appName, appNamespace) => 550 ctx.navigation.goto('.', {syncApp: appName, appNamespace}, {replace: true}) 551 } 552 refreshApplication={refreshApp} 553 deleteApplication={(appName, appNamespace) => 554 AppUtils.deleteApplication(appName, appNamespace, ctx) 555 } 556 /> 557 ) 558 } 559 </Paginate> 560 )} 561 </> 562 )} 563 <ApplicationsSyncPanel 564 key='syncsPanel' 565 show={syncAppsInput} 566 hide={() => ctx.navigation.goto('.', {syncApps: null}, {replace: true})} 567 apps={filteredApps} 568 /> 569 <ApplicationsRefreshPanel 570 key='refreshPanel' 571 show={refreshAppsInput} 572 hide={() => ctx.navigation.goto('.', {refreshApps: null}, {replace: true})} 573 apps={filteredApps} 574 /> 575 </div> 576 <ObservableQuery> 577 {q => ( 578 <DataLoader 579 load={() => 580 q.pipe( 581 mergeMap(params => { 582 const syncApp = params.get('syncApp'); 583 const appNamespace = params.get('appNamespace'); 584 return (syncApp && from(services.applications.get(syncApp, appNamespace))) || from([null]); 585 }) 586 ) 587 }> 588 {app => ( 589 <ApplicationSyncPanel 590 key='syncPanel' 591 application={app} 592 selectedResource={'all'} 593 hide={() => ctx.navigation.goto('.', {syncApp: null}, {replace: true})} 594 /> 595 )} 596 </DataLoader> 597 )} 598 </ObservableQuery> 599 <SlidingPanel 600 isShown={!!appInput} 601 onClose={() => ctx.navigation.goto('.', {new: null}, {replace: true})} 602 header={ 603 <div> 604 <button 605 qe-id='applications-list-button-create' 606 className='argo-button argo-button--base' 607 disabled={isAppCreatePending} 608 onClick={() => createApi && createApi.submitForm(null)}> 609 <Spinner show={isAppCreatePending} style={{marginRight: '5px'}} /> 610 Create 611 </button>{' '} 612 <button 613 qe-id='applications-list-button-cancel' 614 onClick={() => ctx.navigation.goto('.', {new: null}, {replace: true})} 615 className='argo-button argo-button--base-o'> 616 Cancel 617 </button> 618 </div> 619 }> 620 {appInput && ( 621 <ApplicationCreatePanel 622 getFormApi={api => { 623 setCreateApi(api); 624 }} 625 createApp={async app => { 626 setAppCreatePending(true); 627 try { 628 await services.applications.create(app); 629 ctx.navigation.goto('.', {new: null}, {replace: true}); 630 } catch (e) { 631 ctx.notifications.show({ 632 content: <ErrorNotification title='Unable to create application' e={e} />, 633 type: NotificationType.Error 634 }); 635 } finally { 636 setAppCreatePending(false); 637 } 638 }} 639 app={appInput} 640 onAppChanged={app => ctx.navigation.goto('.', {new: JSON.stringify(app)}, {replace: true})} 641 /> 642 )} 643 </SlidingPanel> 644 </React.Fragment> 645 ); 646 }} 647 </DataLoader> 648 </Page> 649 )} 650 </ViewPref> 651 )} 652 </Consumer> 653 </KeybindingProvider> 654 </ClusterCtx.Provider> 655 ); 656 };