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