github.com/argoproj/argo-cd@v1.8.7/ui/src/app/applications/components/application-details/application-details.tsx (about) 1 import {Checkbox as ArgoCheckbox, DropDownMenu, MenuItem, NotificationType, SlidingPanel, Tab, Tabs, TopBarFilter} from 'argo-ui'; 2 import * as classNames from 'classnames'; 3 import * as PropTypes from 'prop-types'; 4 import * as React from 'react'; 5 import {Checkbox} from 'react-form'; 6 import {RouteComponentProps} from 'react-router'; 7 import {BehaviorSubject, Observable} from 'rxjs'; 8 import {DataLoader, EmptyState, ErrorNotification, EventsList, ObservableQuery, Page, Paginate, YamlEditor} from '../../../shared/components'; 9 import {AppContext} from '../../../shared/context'; 10 import * as appModels from '../../../shared/models'; 11 import {AppDetailsPreferences, AppsDetailsViewType, services} from '../../../shared/services'; 12 13 import {SyncStatuses} from '../../../shared/models'; 14 import {ApplicationConditions} from '../application-conditions/application-conditions'; 15 import {ApplicationDeploymentHistory} from '../application-deployment-history/application-deployment-history'; 16 import {ApplicationNodeInfo} from '../application-node-info/application-node-info'; 17 import {ApplicationOperationState} from '../application-operation-state/application-operation-state'; 18 import {ApplicationParameters} from '../application-parameters/application-parameters'; 19 import {ApplicationResourceEvents} from '../application-resource-events/application-resource-events'; 20 import {ApplicationResourceTree, ResourceTreeNode} from '../application-resource-tree/application-resource-tree'; 21 import {ApplicationResourcesDiff} from '../application-resources-diff/application-resources-diff'; 22 import {ApplicationStatusPanel} from '../application-status-panel/application-status-panel'; 23 import {ApplicationSummary} from '../application-summary/application-summary'; 24 import {ApplicationSyncPanel} from '../application-sync-panel/application-sync-panel'; 25 import {PodsLogsViewer} from '../pod-logs-viewer/pod-logs-viewer'; 26 import * as AppUtils from '../utils'; 27 import {isSameNode, nodeKey} from '../utils'; 28 import {ApplicationResourceList} from './application-resource-list'; 29 30 const jsonMergePatch = require('json-merge-patch'); 31 32 require('./application-details.scss'); 33 34 type ActionMenuItem = MenuItem & {disabled?: boolean}; 35 36 export class ApplicationDetails extends React.Component<RouteComponentProps<{name: string}>, {page: number}> { 37 public static contextTypes = { 38 apis: PropTypes.object 39 }; 40 41 private appChanged = new BehaviorSubject<appModels.Application>(null); 42 43 constructor(props: RouteComponentProps<{name: string}>) { 44 super(props); 45 this.state = {page: 0}; 46 } 47 48 private get showOperationState() { 49 return new URLSearchParams(this.props.history.location.search).get('operation') === 'true'; 50 } 51 52 private get showConditions() { 53 return new URLSearchParams(this.props.history.location.search).get('conditions') === 'true'; 54 } 55 56 private get selectedRollbackDeploymentIndex() { 57 return parseInt(new URLSearchParams(this.props.history.location.search).get('rollback'), 10); 58 } 59 60 private get selectedNodeInfo() { 61 const nodeContainer = {key: '', container: 0}; 62 const node = new URLSearchParams(this.props.location.search).get('node'); 63 if (node) { 64 const parts = node.split('/'); 65 nodeContainer.key = parts.slice(0, 4).join('/'); 66 nodeContainer.container = parseInt(parts[4] || '0', 10); 67 } 68 return nodeContainer; 69 } 70 71 private get selectedNodeKey() { 72 const nodeContainer = this.selectedNodeInfo; 73 return nodeContainer.key; 74 } 75 76 public render() { 77 return ( 78 <ObservableQuery> 79 {q => ( 80 <DataLoader 81 errorRenderer={error => <Page title='Application Details'>{error}</Page>} 82 loadingRenderer={() => <Page title='Application Details'>Loading...</Page>} 83 input={this.props.match.params.name} 84 load={name => 85 Observable.combineLatest(this.loadAppInfo(name), services.viewPreferences.getPreferences(), q).map(items => { 86 const pref = items[1].appDetails; 87 const params = items[2]; 88 if (params.get('resource') != null) { 89 pref.resourceFilter = params 90 .get('resource') 91 .split(',') 92 .filter(item => !!item); 93 } 94 if (params.get('view') != null) { 95 pref.view = params.get('view') as AppsDetailsViewType; 96 } 97 if (params.get('orphaned') != null) { 98 pref.orphanedResources = params.get('orphaned') === 'true'; 99 } 100 return {...items[0], pref}; 101 }) 102 }> 103 {({application, tree, pref}: {application: appModels.Application; tree: appModels.ApplicationTree; pref: AppDetailsPreferences}) => { 104 tree.nodes = tree.nodes || []; 105 const kindsSet = new Set<string>(tree.nodes.map(item => item.kind)); 106 const treeFilter = this.getTreeFilter(pref.resourceFilter); 107 treeFilter.kind.forEach(kind => { 108 kindsSet.add(kind); 109 }); 110 const kinds = Array.from(kindsSet); 111 const noKindsFilter = pref.resourceFilter.filter(item => item.indexOf('kind:') !== 0); 112 const refreshing = application.metadata.annotations && application.metadata.annotations[appModels.AnnotationRefreshKey]; 113 114 const filter: TopBarFilter<string> = { 115 items: [ 116 {content: () => <span>Sync</span>}, 117 {value: 'sync:Synced', label: 'Synced'}, 118 // Unhealthy includes 'Unknown' and 'OutOfSync' 119 {value: 'sync:OutOfSync', label: 'OutOfSync'}, 120 {content: () => <span>Health</span>}, 121 {value: 'health:Healthy', label: 'Healthy'}, 122 {value: 'health:Progressing', label: 'Progressing'}, 123 {value: 'health:Degraded', label: 'Degraded'}, 124 {value: 'health:Missing', label: 'Missing'}, 125 {value: 'health:Unknown', label: 'Unknown'}, 126 { 127 content: setSelection => ( 128 <div> 129 Kinds <a onClick={() => setSelection(noKindsFilter.concat(kinds.map(kind => `kind:${kind}`)))}>all</a> /{' '} 130 <a onClick={() => setSelection(noKindsFilter)}>none</a> 131 </div> 132 ) 133 }, 134 ...kinds.sort().map(kind => ({value: `kind:${kind}`, label: kind})) 135 ], 136 selectedValues: pref.resourceFilter, 137 selectionChanged: items => { 138 this.appContext.apis.navigation.goto('.', {resource: `${items.join(',')}`}); 139 services.viewPreferences.updatePreferences({appDetails: {...pref, resourceFilter: items}}); 140 } 141 }; 142 143 const appNodesByName = this.groupAppNodesByKey(application, tree); 144 const selectedItem = (this.selectedNodeKey && appNodesByName.get(this.selectedNodeKey)) || null; 145 const isAppSelected = selectedItem === application; 146 const selectedNode = !isAppSelected && (selectedItem as appModels.ResourceNode); 147 const operationState = application.status.operationState; 148 const conditions = application.status.conditions || []; 149 const syncResourceKey = new URLSearchParams(this.props.history.location.search).get('deploy'); 150 const tab = new URLSearchParams(this.props.history.location.search).get('tab'); 151 const filteredRes = application.status.resources.filter(res => { 152 const resNode: ResourceTreeNode = {...res, root: null, info: null, parentRefs: [], resourceVersion: '', uid: ''}; 153 resNode.root = resNode; 154 return this.filterTreeNode(resNode, treeFilter); 155 }); 156 return ( 157 <div className='application-details'> 158 <Page 159 title='Application Details' 160 toolbar={{ 161 filter, 162 breadcrumbs: [{title: 'Applications', path: '/applications'}, {title: this.props.match.params.name}], 163 actionMenu: {items: this.getApplicationActionMenu(application)}, 164 tools: ( 165 <React.Fragment key='app-list-tools'> 166 <div className='application-details__view-type'> 167 <i 168 className={classNames('fa fa-sitemap', {selected: pref.view === 'tree'})} 169 title='Tree' 170 onClick={() => { 171 this.appContext.apis.navigation.goto('.', {view: 'tree'}); 172 services.viewPreferences.updatePreferences({appDetails: {...pref, view: 'tree'}}); 173 }} 174 /> 175 <i 176 className={classNames('fa fa-network-wired', {selected: pref.view === 'network'})} 177 title='Network' 178 onClick={() => { 179 this.appContext.apis.navigation.goto('.', {view: 'network'}); 180 services.viewPreferences.updatePreferences({appDetails: {...pref, view: 'network'}}); 181 }} 182 /> 183 <i 184 className={classNames('fa fa-th-list', {selected: pref.view === 'list'})} 185 title='List' 186 onClick={() => { 187 this.appContext.apis.navigation.goto('.', {view: 'list'}); 188 services.viewPreferences.updatePreferences({appDetails: {...pref, view: 'list'}}); 189 }} 190 /> 191 </div> 192 </React.Fragment> 193 ) 194 }}> 195 <div className='application-details__status-panel'> 196 <ApplicationStatusPanel 197 application={application} 198 showOperation={() => this.setOperationStatusVisible(true)} 199 showConditions={() => this.setConditionsStatusVisible(true)} 200 /> 201 </div> 202 <div className='application-details__tree'> 203 {refreshing && <p className='application-details__refreshing-label'>Refreshing</p>} 204 {(tree.orphanedNodes || []).length > 0 && ( 205 <div className='application-details__orphaned-filter'> 206 <ArgoCheckbox 207 checked={!!pref.orphanedResources} 208 id='orphanedFilter' 209 onChange={val => { 210 this.appContext.apis.navigation.goto('.', {orphaned: val}); 211 services.viewPreferences.updatePreferences({appDetails: {...pref, orphanedResources: val}}); 212 }} 213 />{' '} 214 <label htmlFor='orphanedFilter'>SHOW ORPHANED</label> 215 </div> 216 )} 217 {((pref.view === 'tree' || pref.view === 'network') && ( 218 <ApplicationResourceTree 219 nodeFilter={node => this.filterTreeNode(node, treeFilter)} 220 selectedNodeFullName={this.selectedNodeKey} 221 onNodeClick={fullName => this.selectNode(fullName)} 222 nodeMenu={node => this.renderResourceMenu(node, application)} 223 tree={tree} 224 app={application} 225 showOrphanedResources={pref.orphanedResources} 226 useNetworkingHierarchy={pref.view === 'network'} 227 onClearFilter={() => { 228 this.appContext.apis.navigation.goto('.', {resource: ''}); 229 services.viewPreferences.updatePreferences({appDetails: {...pref, resourceFilter: []}}); 230 }} 231 /> 232 )) || ( 233 <div> 234 {(filteredRes.length > 0 && ( 235 <Paginate 236 page={this.state.page} 237 data={filteredRes} 238 onPageChange={page => this.setState({page})} 239 preferencesKey='application-details'> 240 {data => ( 241 <ApplicationResourceList 242 onNodeClick={fullName => this.selectNode(fullName)} 243 resources={data} 244 nodeMenu={node => this.renderResourceMenu({...node, root: node}, application)} 245 /> 246 )} 247 </Paginate> 248 )) || ( 249 <EmptyState icon='fa fa-search'> 250 <h4>No resources found</h4> 251 <h5>Try to change filter criteria</h5> 252 </EmptyState> 253 )} 254 </div> 255 )} 256 </div> 257 <SlidingPanel isShown={selectedNode != null || isAppSelected} onClose={() => this.selectNode('')}> 258 <div> 259 {selectedNode && ( 260 <DataLoader 261 noLoaderOnInputChange={true} 262 input={selectedNode.resourceVersion} 263 load={async () => { 264 const managedResources = await services.applications.managedResources(application.metadata.name, { 265 id: {name: selectedNode.name, namespace: selectedNode.namespace, kind: selectedNode.kind, group: selectedNode.group} 266 }); 267 const controlled = managedResources.find(item => isSameNode(selectedNode, item)); 268 const summary = application.status.resources.find(item => isSameNode(selectedNode, item)); 269 const controlledState = (controlled && summary && {summary, state: controlled}) || null; 270 const resQuery = {...selectedNode}; 271 if (controlled && controlled.targetState) { 272 resQuery.version = AppUtils.parseApiVersion(controlled.targetState.apiVersion).version; 273 } 274 const liveState = await services.applications.getResource(application.metadata.name, resQuery).catch(() => null); 275 const events = 276 (liveState && 277 (await services.applications.resourceEvents(application.metadata.name, { 278 name: liveState.metadata.name, 279 namespace: liveState.metadata.namespace, 280 uid: liveState.metadata.uid 281 }))) || 282 []; 283 284 return {controlledState, liveState, events}; 285 }}> 286 {data => ( 287 <Tabs 288 navTransparent={true} 289 tabs={this.getResourceTabs(application, selectedNode, data.liveState, data.events, [ 290 { 291 title: 'SUMMARY', 292 key: 'summary', 293 content: ( 294 <ApplicationNodeInfo 295 application={application} 296 live={data.liveState} 297 controlled={data.controlledState} 298 node={selectedNode} 299 /> 300 ) 301 } 302 ])} 303 selectedTabKey={tab} 304 onTabSelected={selected => this.appContext.apis.navigation.goto('.', {tab: selected})} 305 /> 306 )} 307 </DataLoader> 308 )} 309 {isAppSelected && ( 310 <Tabs 311 navTransparent={true} 312 tabs={[ 313 { 314 title: 'SUMMARY', 315 key: 'summary', 316 content: <ApplicationSummary app={application} updateApp={app => this.updateApp(app)} /> 317 }, 318 { 319 title: 'PARAMETERS', 320 key: 'parameters', 321 content: ( 322 <DataLoader 323 key='appDetails' 324 input={application.spec.source} 325 load={src => 326 services.repos 327 .appDetails(src) 328 .catch(() => ({type: 'Directory' as appModels.AppSourceType, path: application.spec.source.path})) 329 }> 330 {(details: appModels.RepoAppDetails) => ( 331 <ApplicationParameters save={app => this.updateApp(app)} application={application} details={details} /> 332 )} 333 </DataLoader> 334 ) 335 }, 336 { 337 title: 'MANIFEST', 338 key: 'manifest', 339 content: ( 340 <YamlEditor 341 minHeight={800} 342 input={application.spec} 343 onSave={async patch => { 344 const spec = JSON.parse(JSON.stringify(application.spec)); 345 return services.applications.updateSpec( 346 application.metadata.name, 347 jsonMergePatch.apply(spec, JSON.parse(patch)) 348 ); 349 }} 350 /> 351 ) 352 }, 353 { 354 icon: 'fa fa-file-medical', 355 title: 'DIFF', 356 key: 'diff', 357 content: ( 358 <DataLoader 359 key='diff' 360 load={async () => 361 await services.applications.managedResources(application.metadata.name, { 362 fields: [ 363 'items.normalizedLiveState', 364 'items.predictedLiveState', 365 'items.group', 366 'items.kind', 367 'items.namespace', 368 'items.name' 369 ] 370 }) 371 }> 372 {managedResources => <ApplicationResourcesDiff states={managedResources} />} 373 </DataLoader> 374 ) 375 }, 376 { 377 title: 'EVENTS', 378 key: 'event', 379 content: <ApplicationResourceEvents applicationName={application.metadata.name} /> 380 } 381 ]} 382 selectedTabKey={tab} 383 onTabSelected={selected => this.appContext.apis.navigation.goto('.', {tab: selected})} 384 /> 385 )} 386 </div> 387 </SlidingPanel> 388 <ApplicationSyncPanel application={application} hide={() => this.showDeploy(null)} selectedResource={syncResourceKey} /> 389 <SlidingPanel isShown={this.selectedRollbackDeploymentIndex > -1} onClose={() => this.setRollbackPanelVisible(-1)}> 390 {this.selectedRollbackDeploymentIndex > -1 && ( 391 <ApplicationDeploymentHistory 392 app={application} 393 selectedRollbackDeploymentIndex={this.selectedRollbackDeploymentIndex} 394 rollbackApp={info => this.rollbackApplication(info, application)} 395 selectDeployment={i => this.setRollbackPanelVisible(i)} 396 /> 397 )} 398 </SlidingPanel> 399 <SlidingPanel isShown={this.showOperationState && !!operationState} onClose={() => this.setOperationStatusVisible(false)}> 400 {operationState && <ApplicationOperationState application={application} operationState={operationState} />} 401 </SlidingPanel> 402 <SlidingPanel isShown={this.showConditions && !!conditions} onClose={() => this.setConditionsStatusVisible(false)}> 403 {conditions && <ApplicationConditions conditions={conditions} />} 404 </SlidingPanel> 405 </Page> 406 </div> 407 ); 408 }} 409 </DataLoader> 410 )} 411 </ObservableQuery> 412 ); 413 } 414 415 private getApplicationActionMenu(app: appModels.Application) { 416 const refreshing = app.metadata.annotations && app.metadata.annotations[appModels.AnnotationRefreshKey]; 417 const fullName = nodeKey({group: 'argoproj.io', kind: app.kind, name: app.metadata.name, namespace: app.metadata.namespace}); 418 return [ 419 { 420 iconClassName: 'fa fa-info-circle', 421 title: <span className='show-for-medium'>App Details</span>, 422 action: () => this.selectNode(fullName) 423 }, 424 { 425 iconClassName: 'fa fa-file-medical', 426 title: <span className='show-for-medium'>App Diff</span>, 427 action: () => this.selectNode(fullName, 0, 'diff'), 428 disabled: app.status.sync.status === SyncStatuses.Synced 429 }, 430 { 431 iconClassName: 'fa fa-sync', 432 title: <span className='show-for-medium'>Sync</span>, 433 action: () => this.showDeploy('all') 434 }, 435 { 436 iconClassName: 'fa fa-info-circle', 437 title: <span className='show-for-medium'>Sync Status</span>, 438 action: () => this.setOperationStatusVisible(true), 439 disabled: !app.status.operationState 440 }, 441 { 442 iconClassName: 'fa fa-history', 443 title: <span className='show-for-medium'>History and rollback</span>, 444 action: () => this.setRollbackPanelVisible(0), 445 disabled: !app.status.operationState 446 }, 447 { 448 iconClassName: 'fa fa-times-circle', 449 title: <span className='show-for-medium'>Delete</span>, 450 action: () => this.deleteApplication() 451 }, 452 { 453 iconClassName: classNames('fa fa-redo', {'status-icon--spin': !!refreshing}), 454 title: ( 455 <React.Fragment> 456 <span className='show-for-medium'>Refresh</span>{' '} 457 <DropDownMenu 458 items={[ 459 { 460 title: 'Hard Refresh', 461 action: () => !refreshing && services.applications.get(app.metadata.name, 'hard') 462 } 463 ]} 464 anchor={() => <i className='fa fa-caret-down' />} 465 /> 466 </React.Fragment> 467 ), 468 disabled: !!refreshing, 469 action: () => { 470 if (!refreshing) { 471 services.applications.get(app.metadata.name, 'normal'); 472 AppUtils.setAppRefreshing(app); 473 this.appChanged.next(app); 474 } 475 } 476 } 477 ]; 478 } 479 480 private filterTreeNode(node: ResourceTreeNode, filter: {kind: string[]; health: string[]; sync: string[]}): boolean { 481 const syncStatuses = filter.sync.map(item => (item === 'OutOfSync' ? ['OutOfSync', 'Unknown'] : [item])).reduce((first, second) => first.concat(second), []); 482 483 return ( 484 (filter.kind.length === 0 || filter.kind.indexOf(node.kind) > -1) && 485 (syncStatuses.length === 0 || node.root.hook || (node.root.status && syncStatuses.indexOf(node.root.status) > -1)) && 486 (filter.health.length === 0 || node.root.hook || (node.root.health && filter.health.indexOf(node.root.health.status) > -1)) 487 ); 488 } 489 490 private loadAppInfo(name: string): Observable<{application: appModels.Application; tree: appModels.ApplicationTree}> { 491 return Observable.fromPromise(services.applications.get(name)) 492 .flatMap(app => { 493 const fallbackTree = { 494 nodes: app.status.resources.map(res => ({...res, parentRefs: [], info: [], resourceVersion: '', uid: ''})), 495 orphanedNodes: [] 496 } as appModels.ApplicationTree; 497 return Observable.combineLatest( 498 Observable.merge( 499 Observable.from([app]), 500 this.appChanged.filter(item => !!item), 501 AppUtils.handlePageVisibility(() => 502 services.applications 503 .watch({name}) 504 .map(watchEvent => { 505 if (watchEvent.type === 'DELETED') { 506 this.onAppDeleted(); 507 } 508 return watchEvent.application; 509 }) 510 .repeat() 511 .retryWhen(errors => errors.delay(500)) 512 ) 513 ), 514 Observable.merge( 515 Observable.from([fallbackTree]), 516 services.applications.resourceTree(name).catch(() => fallbackTree), 517 AppUtils.handlePageVisibility(() => 518 services.applications 519 .watchResourceTree(name) 520 .repeat() 521 .retryWhen(errors => errors.delay(500)) 522 ) 523 ) 524 ); 525 }) 526 .filter(([application, tree]) => !!application && !!tree) 527 .map(([application, tree]) => ({application, tree})); 528 } 529 530 private onAppDeleted() { 531 this.appContext.apis.notifications.show({type: NotificationType.Success, content: `Application '${this.props.match.params.name}' was deleted`}); 532 this.appContext.apis.navigation.goto('/applications'); 533 } 534 535 private async updateApp(app: appModels.Application) { 536 const latestApp = await services.applications.get(app.metadata.name); 537 latestApp.metadata.labels = app.metadata.labels; 538 latestApp.metadata.annotations = app.metadata.annotations; 539 latestApp.spec = app.spec; 540 const updatedApp = await services.applications.update(latestApp); 541 this.appChanged.next(updatedApp); 542 } 543 544 private groupAppNodesByKey(application: appModels.Application, tree: appModels.ApplicationTree) { 545 const nodeByKey = new Map<string, appModels.ResourceDiff | appModels.ResourceNode | appModels.Application>(); 546 tree.nodes.concat(tree.orphanedNodes || []).forEach(node => nodeByKey.set(nodeKey(node), node)); 547 nodeByKey.set(nodeKey({group: 'argoproj.io', kind: application.kind, name: application.metadata.name, namespace: application.metadata.namespace}), application); 548 return nodeByKey; 549 } 550 551 private getTreeFilter(filter: string[]): {kind: string[]; health: string[]; sync: string[]} { 552 const kind = new Array<string>(); 553 const health = new Array<string>(); 554 const sync = new Array<string>(); 555 for (const item of filter) { 556 const [type, val] = item.split(':'); 557 switch (type) { 558 case 'kind': 559 kind.push(val); 560 break; 561 case 'health': 562 health.push(val); 563 break; 564 case 'sync': 565 sync.push(val); 566 break; 567 } 568 } 569 return {kind, health, sync}; 570 } 571 572 private showDeploy(resource: string) { 573 this.appContext.apis.navigation.goto('.', {deploy: resource}); 574 } 575 576 private setOperationStatusVisible(isVisible: boolean) { 577 this.appContext.apis.navigation.goto('.', {operation: isVisible}); 578 } 579 580 private setConditionsStatusVisible(isVisible: boolean) { 581 this.appContext.apis.navigation.goto('.', {conditions: isVisible}); 582 } 583 584 private setRollbackPanelVisible(selectedDeploymentIndex = 0) { 585 this.appContext.apis.navigation.goto('.', {rollback: selectedDeploymentIndex}); 586 } 587 588 private selectNode(fullName: string, containerIndex = 0, tab: string = null) { 589 const node = fullName ? `${fullName}/${containerIndex}` : null; 590 this.appContext.apis.navigation.goto('.', {node, tab}); 591 } 592 593 private async rollbackApplication(revisionHistory: appModels.RevisionHistory, application: appModels.Application) { 594 try { 595 const needDisableRollback = application.spec.syncPolicy && application.spec.syncPolicy.automated; 596 let confirmationMessage = `Are you sure you want to rollback application '${this.props.match.params.name}'?`; 597 if (needDisableRollback) { 598 confirmationMessage = `Auto-Sync needs to be disabled in order for rollback to occur. 599 Are you sure you want to disable auto-sync and rollback application '${this.props.match.params.name}'?`; 600 } 601 602 const confirmed = await this.appContext.apis.popup.confirm('Rollback application', confirmationMessage); 603 if (confirmed) { 604 if (needDisableRollback) { 605 const update = JSON.parse(JSON.stringify(application)) as appModels.Application; 606 update.spec.syncPolicy = {automated: null}; 607 await services.applications.update(update); 608 } 609 await services.applications.rollback(this.props.match.params.name, revisionHistory.id); 610 this.appChanged.next(await services.applications.get(this.props.match.params.name)); 611 this.setRollbackPanelVisible(-1); 612 } 613 } catch (e) { 614 this.appContext.apis.notifications.show({ 615 content: <ErrorNotification title='Unable to rollback application' e={e} />, 616 type: NotificationType.Error 617 }); 618 } 619 } 620 621 private get appContext(): AppContext { 622 return this.context as AppContext; 623 } 624 625 private renderResourceMenu(resource: ResourceTreeNode, application: appModels.Application): React.ReactNode { 626 let menuItems: Observable<ActionMenuItem[]>; 627 if (AppUtils.isAppNode(resource) && resource.name === application.metadata.name) { 628 menuItems = Observable.from([this.getApplicationActionMenu(application)]); 629 } else { 630 const isRoot = resource.root && AppUtils.nodeKey(resource.root) === AppUtils.nodeKey(resource); 631 const items: MenuItem[] = [ 632 ...((isRoot && [ 633 { 634 title: 'Sync', 635 action: () => this.showDeploy(nodeKey(resource)) 636 } 637 ]) || 638 []), 639 { 640 title: 'Delete', 641 action: async () => { 642 this.appContext.apis.popup.prompt( 643 'Delete resource', 644 () => ( 645 <div> 646 <p> 647 Are your sure you want to delete {resource.kind} '{resource.name}'? 648 </p> 649 <div className='argo-form-row' style={{paddingLeft: '30px'}}> 650 <Checkbox id='force-delete-checkbox' field='force' /> <label htmlFor='force-delete-checkbox'>Force delete</label> 651 </div> 652 </div> 653 ), 654 { 655 submit: async (vals, _, close) => { 656 try { 657 await services.applications.deleteResource(this.props.match.params.name, resource, !!vals.force); 658 this.appChanged.next(await services.applications.get(this.props.match.params.name)); 659 close(); 660 } catch (e) { 661 this.appContext.apis.notifications.show({ 662 content: <ErrorNotification title='Unable to delete resource' e={e} />, 663 type: NotificationType.Error 664 }); 665 } 666 } 667 } 668 ); 669 } 670 } 671 ]; 672 const resourceActions = services.applications 673 .getResourceActions(application.metadata.name, resource) 674 .then(actions => 675 items.concat( 676 actions.map(action => ({ 677 title: action.name, 678 disabled: !!action.disabled, 679 action: async () => { 680 try { 681 const confirmed = await this.appContext.apis.popup.confirm( 682 `Execute '${action.name}' action?`, 683 `Are you sure you want to execute '${action.name}' action?` 684 ); 685 if (confirmed) { 686 await services.applications.runResourceAction(application.metadata.name, resource, action.name); 687 } 688 } catch (e) { 689 this.appContext.apis.notifications.show({ 690 content: <ErrorNotification title='Unable to execute resource action' e={e} />, 691 type: NotificationType.Error 692 }); 693 } 694 } 695 })) 696 ) 697 ) 698 .catch(() => items); 699 menuItems = Observable.merge(Observable.from([items]), Observable.fromPromise(resourceActions)); 700 } 701 return ( 702 <DataLoader load={() => menuItems}> 703 {items => ( 704 <ul> 705 {items.map((item, i) => ( 706 <li 707 className={classNames('application-details__action-menu', {disabled: item.disabled})} 708 key={i} 709 onClick={e => { 710 e.stopPropagation(); 711 if (!item.disabled) { 712 item.action(); 713 document.body.click(); 714 } 715 }}> 716 {item.iconClassName && <i className={item.iconClassName} />} {item.title} 717 </li> 718 ))} 719 </ul> 720 )} 721 </DataLoader> 722 ); 723 } 724 725 private async deleteApplication() { 726 await AppUtils.deleteApplication(this.props.match.params.name, this.appContext.apis); 727 } 728 729 private getResourceTabs(application: appModels.Application, node: appModels.ResourceNode, state: appModels.State, events: appModels.Event[], tabs: Tab[]) { 730 if (state) { 731 const numErrors = events.filter(event => event.type !== 'Normal').reduce((total, event) => total + event.count, 0); 732 tabs.push({ 733 title: 'EVENTS', 734 badge: (numErrors > 0 && numErrors) || null, 735 key: 'events', 736 content: ( 737 <div className='application-resource-events'> 738 <EventsList events={events} /> 739 </div> 740 ) 741 }); 742 } 743 if (node.kind === 'Pod' && state) { 744 const containerGroups = [ 745 { 746 offset: 0, 747 title: 'INIT CONTAINERS', 748 containers: state.spec.initContainers || [] 749 }, 750 { 751 offset: (state.spec.initContainers || []).length, 752 title: 'CONTAINERS', 753 containers: state.spec.containers || [] 754 } 755 ]; 756 tabs = tabs.concat([ 757 { 758 key: 'logs', 759 title: 'LOGS', 760 content: ( 761 <div className='application-details__tab-content-full-height'> 762 <div className='row'> 763 <div className='columns small-3 medium-2'> 764 {containerGroups.map(group => ( 765 <div key={group.title} style={{marginBottom: '1em'}}> 766 {group.containers.length > 0 && <p>{group.title}:</p>} 767 {group.containers.map((container: any, i: number) => ( 768 <div 769 className='application-details__container' 770 key={container.name} 771 onClick={() => this.selectNode(this.selectedNodeKey, group.offset + i, 'logs')}> 772 {group.offset + i === this.selectedNodeInfo.container && <i className='fa fa-angle-right' />} 773 <span title={container.name}>{container.name}</span> 774 </div> 775 ))} 776 </div> 777 ))} 778 </div> 779 <div className='columns small-9 medium-10'> 780 <PodsLogsViewer pod={state} applicationName={application.metadata.name} containerIndex={this.selectedNodeInfo.container} /> 781 </div> 782 </div> 783 </div> 784 ) 785 } 786 ]); 787 } 788 return tabs; 789 } 790 }