github.com/argoproj/argo-cd/v2@v2.10.9/ui/src/app/applications/components/application-details/application-details.tsx (about) 1 import {DropDownMenu, NotificationType, SlidingPanel, Tooltip} from 'argo-ui'; 2 import * as classNames from 'classnames'; 3 import * as PropTypes from 'prop-types'; 4 import * as React from 'react'; 5 import * as ReactDOM from 'react-dom'; 6 import * as models from '../../../shared/models'; 7 import {RouteComponentProps} from 'react-router'; 8 import {BehaviorSubject, combineLatest, from, merge, Observable} from 'rxjs'; 9 import {delay, filter, map, mergeMap, repeat, retryWhen} from 'rxjs/operators'; 10 11 import {DataLoader, EmptyState, ErrorNotification, ObservableQuery, Page, Paginate, Revision, Timestamp} from '../../../shared/components'; 12 import {AppContext, ContextApis} from '../../../shared/context'; 13 import * as appModels from '../../../shared/models'; 14 import {AppDetailsPreferences, AppsDetailsViewKey, AppsDetailsViewType, services} from '../../../shared/services'; 15 16 import {ApplicationConditions} from '../application-conditions/application-conditions'; 17 import {ApplicationDeploymentHistory} from '../application-deployment-history/application-deployment-history'; 18 import {ApplicationOperationState} from '../application-operation-state/application-operation-state'; 19 import {PodGroupType, PodView} from '../application-pod-view/pod-view'; 20 import {ApplicationResourceTree, ResourceTreeNode} from '../application-resource-tree/application-resource-tree'; 21 import {ApplicationStatusPanel} from '../application-status-panel/application-status-panel'; 22 import {ApplicationSyncPanel} from '../application-sync-panel/application-sync-panel'; 23 import {ResourceDetails} from '../resource-details/resource-details'; 24 import * as AppUtils from '../utils'; 25 import {ApplicationResourceList} from './application-resource-list'; 26 import {Filters, FiltersProps} from './application-resource-filter'; 27 import {getAppDefaultSource, urlPattern, helpTip} from '../utils'; 28 import {ChartDetails, ResourceStatus} from '../../../shared/models'; 29 import {ApplicationsDetailsAppDropdown} from './application-details-app-dropdown'; 30 import {useSidebarTarget} from '../../../sidebar/sidebar'; 31 32 import './application-details.scss'; 33 import {AppViewExtension, StatusPanelExtension} from '../../../shared/services/extensions-service'; 34 35 interface ApplicationDetailsState { 36 page: number; 37 revision?: string; 38 groupedResources?: ResourceStatus[]; 39 slidingPanelPage?: number; 40 filteredGraph?: any[]; 41 truncateNameOnRight?: boolean; 42 collapsedNodes?: string[]; 43 extensions?: AppViewExtension[]; 44 extensionsMap?: {[key: string]: AppViewExtension}; 45 statusExtensions?: StatusPanelExtension[]; 46 statusExtensionsMap?: {[key: string]: StatusPanelExtension}; 47 } 48 49 interface FilterInput { 50 name: string[]; 51 kind: string[]; 52 health: string[]; 53 sync: string[]; 54 namespace: string[]; 55 } 56 57 const ApplicationDetailsFilters = (props: FiltersProps) => { 58 const sidebarTarget = useSidebarTarget(); 59 return ReactDOM.createPortal(<Filters {...props} />, sidebarTarget?.current); 60 }; 61 62 export const NodeInfo = (node?: string): {key: string; container: number} => { 63 const nodeContainer = {key: '', container: 0}; 64 if (node) { 65 const parts = node.split('/'); 66 nodeContainer.key = parts.slice(0, 4).join('/'); 67 nodeContainer.container = parseInt(parts[4] || '0', 10); 68 } 69 return nodeContainer; 70 }; 71 72 export const SelectNode = (fullName: string, containerIndex = 0, tab: string = null, appContext: ContextApis) => { 73 const node = fullName ? `${fullName}/${containerIndex}` : null; 74 appContext.navigation.goto('.', {node, tab}, {replace: true}); 75 }; 76 77 export class ApplicationDetails extends React.Component<RouteComponentProps<{appnamespace: string; name: string}>, ApplicationDetailsState> { 78 public static contextTypes = { 79 apis: PropTypes.object 80 }; 81 82 private appChanged = new BehaviorSubject<appModels.Application>(null); 83 private appNamespace: string; 84 85 constructor(props: RouteComponentProps<{appnamespace: string; name: string}>) { 86 super(props); 87 const extensions = services.extensions.getAppViewExtensions(); 88 const extensionsMap: {[key: string]: AppViewExtension} = {}; 89 extensions.forEach(ext => { 90 extensionsMap[ext.title] = ext; 91 }); 92 const statusExtensions = services.extensions.getStatusPanelExtensions(); 93 const statusExtensionsMap: {[key: string]: StatusPanelExtension} = {}; 94 statusExtensions.forEach(ext => { 95 statusExtensionsMap[ext.id] = ext; 96 }); 97 this.state = { 98 page: 0, 99 groupedResources: [], 100 slidingPanelPage: 0, 101 filteredGraph: [], 102 truncateNameOnRight: false, 103 collapsedNodes: [], 104 extensions, 105 extensionsMap, 106 statusExtensions, 107 statusExtensionsMap 108 }; 109 if (typeof this.props.match.params.appnamespace === 'undefined') { 110 this.appNamespace = ''; 111 } else { 112 this.appNamespace = this.props.match.params.appnamespace; 113 } 114 } 115 116 private get showOperationState() { 117 return new URLSearchParams(this.props.history.location.search).get('operation') === 'true'; 118 } 119 120 private setNodeExpansion(node: string, isExpanded: boolean) { 121 const index = this.state.collapsedNodes.indexOf(node); 122 if (isExpanded && index >= 0) { 123 this.state.collapsedNodes.splice(index, 1); 124 const updatedNodes = this.state.collapsedNodes.slice(); 125 this.setState({collapsedNodes: updatedNodes}); 126 } else if (!isExpanded && index < 0) { 127 const updatedNodes = this.state.collapsedNodes.slice(); 128 updatedNodes.push(node); 129 this.setState({collapsedNodes: updatedNodes}); 130 } 131 } 132 133 private getNodeExpansion(node: string): boolean { 134 return this.state.collapsedNodes.indexOf(node) < 0; 135 } 136 137 private get showConditions() { 138 return new URLSearchParams(this.props.history.location.search).get('conditions') === 'true'; 139 } 140 141 private get selectedRollbackDeploymentIndex() { 142 return parseInt(new URLSearchParams(this.props.history.location.search).get('rollback'), 10); 143 } 144 145 private get selectedNodeInfo() { 146 return NodeInfo(new URLSearchParams(this.props.history.location.search).get('node')); 147 } 148 149 private get selectedNodeKey() { 150 const nodeContainer = this.selectedNodeInfo; 151 return nodeContainer.key; 152 } 153 154 private get selectedExtension() { 155 return new URLSearchParams(this.props.history.location.search).get('extension'); 156 } 157 158 private closeGroupedNodesPanel() { 159 this.setState({groupedResources: []}); 160 this.setState({slidingPanelPage: 0}); 161 } 162 163 private toggleCompactView(appName: string, pref: AppDetailsPreferences) { 164 pref.userHelpTipMsgs = pref.userHelpTipMsgs.map(usrMsg => (usrMsg.appName === appName && usrMsg.msgKey === 'groupNodes' ? {...usrMsg, display: true} : usrMsg)); 165 services.viewPreferences.updatePreferences({appDetails: {...pref, groupNodes: !pref.groupNodes}}); 166 } 167 168 private getPageTitle(view: string) { 169 const {Tree, Pods, Network, List} = AppsDetailsViewKey; 170 switch (view) { 171 case Tree: 172 return 'Application Details Tree'; 173 case Network: 174 return 'Application Details Network'; 175 case Pods: 176 return 'Application Details Pods'; 177 case List: 178 return 'Application Details List'; 179 } 180 return ''; 181 } 182 183 public render() { 184 return ( 185 <ObservableQuery> 186 {q => ( 187 <DataLoader 188 errorRenderer={error => <Page title='Application Details'>{error}</Page>} 189 loadingRenderer={() => <Page title='Application Details'>Loading...</Page>} 190 input={this.props.match.params.name} 191 load={name => 192 combineLatest([this.loadAppInfo(name, this.appNamespace), services.viewPreferences.getPreferences(), q]).pipe( 193 map(items => { 194 const application = items[0].application; 195 const pref = items[1].appDetails; 196 const params = items[2]; 197 if (params.get('resource') != null) { 198 pref.resourceFilter = params 199 .get('resource') 200 .split(',') 201 .filter(item => !!item); 202 } 203 if (params.get('view') != null) { 204 pref.view = params.get('view') as AppsDetailsViewType; 205 } else { 206 const appDefaultView = (application.metadata && 207 application.metadata.annotations && 208 application.metadata.annotations[appModels.AnnotationDefaultView]) as AppsDetailsViewType; 209 if (appDefaultView != null) { 210 pref.view = appDefaultView; 211 } 212 } 213 if (params.get('orphaned') != null) { 214 pref.orphanedResources = params.get('orphaned') === 'true'; 215 } 216 if (params.get('podSortMode') != null) { 217 pref.podView.sortMode = params.get('podSortMode') as PodGroupType; 218 } else { 219 const appDefaultPodSort = (application.metadata && 220 application.metadata.annotations && 221 application.metadata.annotations[appModels.AnnotationDefaultPodSort]) as PodGroupType; 222 if (appDefaultPodSort != null) { 223 pref.podView.sortMode = appDefaultPodSort; 224 } 225 } 226 return {...items[0], pref}; 227 }) 228 ) 229 }> 230 {({application, tree, pref}: {application: appModels.Application; tree: appModels.ApplicationTree; pref: AppDetailsPreferences}) => { 231 tree.nodes = tree.nodes || []; 232 const treeFilter = this.getTreeFilter(pref.resourceFilter); 233 const setFilter = (items: string[]) => { 234 this.appContext.apis.navigation.goto('.', {resource: items.join(',')}, {replace: true}); 235 services.viewPreferences.updatePreferences({appDetails: {...pref, resourceFilter: items}}); 236 }; 237 const clearFilter = () => setFilter([]); 238 const refreshing = application.metadata.annotations && application.metadata.annotations[appModels.AnnotationRefreshKey]; 239 const appNodesByName = this.groupAppNodesByKey(application, tree); 240 const selectedItem = (this.selectedNodeKey && appNodesByName.get(this.selectedNodeKey)) || null; 241 const isAppSelected = selectedItem === application; 242 const selectedNode = !isAppSelected && (selectedItem as appModels.ResourceNode); 243 const operationState = application.status.operationState; 244 const conditions = application.status.conditions || []; 245 const syncResourceKey = new URLSearchParams(this.props.history.location.search).get('deploy'); 246 const tab = new URLSearchParams(this.props.history.location.search).get('tab'); 247 const source = getAppDefaultSource(application); 248 const showToolTip = pref?.userHelpTipMsgs.find(usrMsg => usrMsg.appName === application.metadata.name); 249 const resourceNodes = (): any[] => { 250 const statusByKey = new Map<string, models.ResourceStatus>(); 251 application.status.resources.forEach(res => statusByKey.set(AppUtils.nodeKey(res), res)); 252 const resources = new Map<string, any>(); 253 tree.nodes 254 .map(node => ({...node, orphaned: false})) 255 .concat(((pref.orphanedResources && tree.orphanedNodes) || []).map(node => ({...node, orphaned: true}))) 256 .forEach(node => { 257 const resource: any = {...node}; 258 resource.uid = node.uid; 259 const status = statusByKey.get(AppUtils.nodeKey(node)); 260 if (status) { 261 resource.health = status.health; 262 resource.status = status.status; 263 resource.hook = status.hook; 264 resource.syncWave = status.syncWave; 265 resource.requiresPruning = status.requiresPruning; 266 } 267 resources.set(node.uid || AppUtils.nodeKey(node), resource); 268 }); 269 const resourcesRef = Array.from(resources.values()); 270 return resourcesRef; 271 }; 272 273 const filteredRes = resourceNodes().filter(res => { 274 const resNode: ResourceTreeNode = {...res, root: null, info: null, parentRefs: [], resourceVersion: '', uid: ''}; 275 resNode.root = resNode; 276 return this.filterTreeNode(resNode, treeFilter); 277 }); 278 const openGroupNodeDetails = (groupdedNodeIds: string[]) => { 279 const resources = resourceNodes(); 280 this.setState({ 281 groupedResources: groupdedNodeIds 282 ? resources.filter(res => groupdedNodeIds.includes(res.uid) || groupdedNodeIds.includes(AppUtils.nodeKey(res))) 283 : [] 284 }); 285 }; 286 287 const renderCommitMessage = (message: string) => 288 message.split(/\s/).map(part => 289 urlPattern.test(part) ? ( 290 <a href={part} target='_blank' rel='noopener noreferrer' style={{overflowWrap: 'anywhere', wordBreak: 'break-word'}}> 291 {part}{' '} 292 </a> 293 ) : ( 294 part + ' ' 295 ) 296 ); 297 const {Tree, Pods, Network, List} = AppsDetailsViewKey; 298 const zoomNum = (pref.zoom * 100).toFixed(0); 299 const setZoom = (s: number) => { 300 let targetZoom: number = pref.zoom + s; 301 if (targetZoom <= 0.05) { 302 targetZoom = 0.1; 303 } else if (targetZoom > 2.0) { 304 targetZoom = 2.0; 305 } 306 services.viewPreferences.updatePreferences({appDetails: {...pref, zoom: targetZoom}}); 307 }; 308 const setFilterGraph = (filterGraph: any[]) => { 309 this.setState({filteredGraph: filterGraph}); 310 }; 311 const setShowCompactNodes = (showCompactView: boolean) => { 312 services.viewPreferences.updatePreferences({appDetails: {...pref, groupNodes: showCompactView}}); 313 }; 314 const updateHelpTipState = (usrHelpTip: models.UserMessages) => { 315 const existingIndex = pref.userHelpTipMsgs.findIndex(msg => msg.appName === usrHelpTip.appName && msg.msgKey === usrHelpTip.msgKey); 316 if (existingIndex !== -1) { 317 pref.userHelpTipMsgs[existingIndex] = usrHelpTip; 318 } else { 319 (pref.userHelpTipMsgs || []).push(usrHelpTip); 320 } 321 }; 322 const toggleNameDirection = () => { 323 this.setState({truncateNameOnRight: !this.state.truncateNameOnRight}); 324 }; 325 const expandAll = () => { 326 this.setState({collapsedNodes: []}); 327 }; 328 const collapseAll = () => { 329 const nodes = new Array<ResourceTreeNode>(); 330 tree.nodes 331 .map(node => ({...node, orphaned: false})) 332 .concat((tree.orphanedNodes || []).map(node => ({...node, orphaned: true}))) 333 .forEach(node => { 334 const resourceNode: ResourceTreeNode = {...node}; 335 nodes.push(resourceNode); 336 }); 337 const collapsedNodesList = this.state.collapsedNodes.slice(); 338 if (pref.view === 'network') { 339 const networkNodes = nodes.filter(node => node.networkingInfo); 340 networkNodes.forEach(parent => { 341 const parentId = parent.uid; 342 if (collapsedNodesList.indexOf(parentId) < 0) { 343 collapsedNodesList.push(parentId); 344 } 345 }); 346 this.setState({collapsedNodes: collapsedNodesList}); 347 } else { 348 const managedKeys = new Set(application.status.resources.map(AppUtils.nodeKey)); 349 nodes.forEach(node => { 350 if (!((node.parentRefs || []).length === 0 || managedKeys.has(AppUtils.nodeKey(node)))) { 351 node.parentRefs.forEach(parent => { 352 const parentId = parent.uid; 353 if (collapsedNodesList.indexOf(parentId) < 0) { 354 collapsedNodesList.push(parentId); 355 } 356 }); 357 } 358 }); 359 collapsedNodesList.push(application.kind + '-' + application.metadata.namespace + '-' + application.metadata.name); 360 this.setState({collapsedNodes: collapsedNodesList}); 361 } 362 }; 363 const appFullName = AppUtils.nodeKey({ 364 group: 'argoproj.io', 365 kind: application.kind, 366 name: application.metadata.name, 367 namespace: application.metadata.namespace 368 }); 369 370 const activeExtension = this.state.statusExtensionsMap[this.selectedExtension]; 371 372 return ( 373 <div className={`application-details ${this.props.match.params.name}`}> 374 <Page 375 title={this.props.match.params.name + ' - ' + this.getPageTitle(pref.view)} 376 useTitleOnly={true} 377 topBarTitle={this.getPageTitle(pref.view)} 378 toolbar={{ 379 breadcrumbs: [ 380 {title: 'Applications', path: '/applications'}, 381 {title: <ApplicationsDetailsAppDropdown appName={this.props.match.params.name} />} 382 ], 383 actionMenu: {items: this.getApplicationActionMenu(application, true)}, 384 tools: ( 385 <React.Fragment key='app-list-tools'> 386 <div className='application-details__view-type'> 387 <i 388 className={classNames('fa fa-sitemap', {selected: pref.view === Tree})} 389 title='Tree' 390 onClick={() => { 391 this.appContext.apis.navigation.goto('.', {view: Tree}); 392 services.viewPreferences.updatePreferences({appDetails: {...pref, view: Tree}}); 393 }} 394 /> 395 <i 396 className={classNames('fa fa-th', {selected: pref.view === Pods})} 397 title='Pods' 398 onClick={() => { 399 this.appContext.apis.navigation.goto('.', {view: Pods}); 400 services.viewPreferences.updatePreferences({appDetails: {...pref, view: Pods}}); 401 }} 402 /> 403 <i 404 className={classNames('fa fa-network-wired', {selected: pref.view === Network})} 405 title='Network' 406 onClick={() => { 407 this.appContext.apis.navigation.goto('.', {view: Network}); 408 services.viewPreferences.updatePreferences({appDetails: {...pref, view: Network}}); 409 }} 410 /> 411 <i 412 className={classNames('fa fa-th-list', {selected: pref.view === List})} 413 title='List' 414 onClick={() => { 415 this.appContext.apis.navigation.goto('.', {view: List}); 416 services.viewPreferences.updatePreferences({appDetails: {...pref, view: List}}); 417 }} 418 /> 419 {this.state.extensions && 420 (this.state.extensions || []).map(ext => ( 421 <i 422 key={ext.title} 423 className={classNames(`fa ${ext.icon}`, {selected: pref.view === ext.title})} 424 title={ext.title} 425 onClick={() => { 426 this.appContext.apis.navigation.goto('.', {view: ext.title}); 427 services.viewPreferences.updatePreferences({appDetails: {...pref, view: ext.title}}); 428 }} 429 /> 430 ))} 431 </div> 432 </React.Fragment> 433 ) 434 }}> 435 <div className='application-details__wrapper'> 436 <div className='application-details__status-panel'> 437 <ApplicationStatusPanel 438 application={application} 439 showDiff={() => this.selectNode(appFullName, 0, 'diff')} 440 showOperation={() => this.setOperationStatusVisible(true)} 441 showConditions={() => this.setConditionsStatusVisible(true)} 442 showExtension={id => this.setExtensionPanelVisible(id)} 443 showMetadataInfo={revision => this.setState({...this.state, revision})} 444 /> 445 </div> 446 <div className='application-details__tree'> 447 {refreshing && <p className='application-details__refreshing-label'>Refreshing</p>} 448 {((pref.view === 'tree' || pref.view === 'network') && ( 449 <> 450 <DataLoader load={() => services.viewPreferences.getPreferences()}> 451 {viewPref => ( 452 <ApplicationDetailsFilters 453 pref={pref} 454 tree={tree} 455 onSetFilter={setFilter} 456 onClearFilter={clearFilter} 457 collapsed={viewPref.hideSidebar} 458 resourceNodes={this.state.filteredGraph} 459 /> 460 )} 461 </DataLoader> 462 <div className='graph-options-panel'> 463 <a 464 className={`group-nodes-button`} 465 onClick={() => { 466 toggleNameDirection(); 467 }} 468 title={this.state.truncateNameOnRight ? 'Truncate resource name right' : 'Truncate resource name left'}> 469 <i 470 className={classNames({ 471 'fa fa-align-right': this.state.truncateNameOnRight, 472 'fa fa-align-left': !this.state.truncateNameOnRight 473 })} 474 /> 475 </a> 476 {(pref.view === 'tree' || pref.view === 'network') && ( 477 <Tooltip 478 content={AppUtils.userMsgsList[showToolTip?.msgKey] || 'Group Nodes'} 479 visible={pref.groupNodes && showToolTip !== undefined && !showToolTip?.display} 480 duration={showToolTip?.duration} 481 zIndex={1}> 482 <a 483 className={`group-nodes-button group-nodes-button${!pref.groupNodes ? '' : '-on'}`} 484 title={pref.view === 'tree' ? 'Group Nodes' : 'Collapse Pods'} 485 onClick={() => this.toggleCompactView(application.metadata.name, pref)}> 486 <i className={classNames('fa fa-object-group fa-fw')} /> 487 </a> 488 </Tooltip> 489 )} 490 491 <span className={`separator`} /> 492 <a className={`group-nodes-button`} onClick={() => expandAll()} title='Expand all child nodes of all parent nodes'> 493 <i className='fa fa-plus fa-fw' /> 494 </a> 495 <a className={`group-nodes-button`} onClick={() => collapseAll()} title='Collapse all child nodes of all parent nodes'> 496 <i className='fa fa-minus fa-fw' /> 497 </a> 498 <span className={`separator`} /> 499 <span> 500 <a className={`group-nodes-button`} onClick={() => setZoom(0.1)} title='Zoom in'> 501 <i className='fa fa-search-plus fa-fw' /> 502 </a> 503 <a className={`group-nodes-button`} onClick={() => setZoom(-0.1)} title='Zoom out'> 504 <i className='fa fa-search-minus fa-fw' /> 505 </a> 506 <div className={`zoom-value`}>{zoomNum}%</div> 507 </span> 508 </div> 509 <ApplicationResourceTree 510 nodeFilter={node => this.filterTreeNode(node, treeFilter)} 511 selectedNodeFullName={this.selectedNodeKey} 512 onNodeClick={fullName => this.selectNode(fullName)} 513 nodeMenu={node => 514 AppUtils.renderResourceMenu(node, application, tree, this.appContext.apis, this.appChanged, () => 515 this.getApplicationActionMenu(application, false) 516 ) 517 } 518 showCompactNodes={pref.groupNodes} 519 userMsgs={pref.userHelpTipMsgs} 520 tree={tree} 521 app={application} 522 showOrphanedResources={pref.orphanedResources} 523 useNetworkingHierarchy={pref.view === 'network'} 524 onClearFilter={clearFilter} 525 onGroupdNodeClick={groupdedNodeIds => openGroupNodeDetails(groupdedNodeIds)} 526 zoom={pref.zoom} 527 podGroupCount={pref.podGroupCount} 528 appContext={this.appContext} 529 nameDirection={this.state.truncateNameOnRight} 530 filters={pref.resourceFilter} 531 setTreeFilterGraph={setFilterGraph} 532 updateUsrHelpTipMsgs={updateHelpTipState} 533 setShowCompactNodes={setShowCompactNodes} 534 setNodeExpansion={(node, isExpanded) => this.setNodeExpansion(node, isExpanded)} 535 getNodeExpansion={node => this.getNodeExpansion(node)} 536 /> 537 </> 538 )) || 539 (pref.view === 'pods' && ( 540 <PodView 541 tree={tree} 542 app={application} 543 onItemClick={fullName => this.selectNode(fullName)} 544 nodeMenu={node => 545 AppUtils.renderResourceMenu(node, application, tree, this.appContext.apis, this.appChanged, () => 546 this.getApplicationActionMenu(application, false) 547 ) 548 } 549 quickStarts={node => AppUtils.renderResourceButtons(node, application, tree, this.appContext.apis, this.appChanged)} 550 /> 551 )) || 552 (this.state.extensionsMap[pref.view] != null && ( 553 <ExtensionView extension={this.state.extensionsMap[pref.view]} application={application} tree={tree} /> 554 )) || ( 555 <div> 556 <DataLoader load={() => services.viewPreferences.getPreferences()}> 557 {viewPref => ( 558 <ApplicationDetailsFilters 559 pref={pref} 560 tree={tree} 561 onSetFilter={setFilter} 562 onClearFilter={clearFilter} 563 collapsed={viewPref.hideSidebar} 564 resourceNodes={filteredRes} 565 /> 566 )} 567 </DataLoader> 568 {(filteredRes.length > 0 && ( 569 <Paginate 570 page={this.state.page} 571 data={filteredRes} 572 onPageChange={page => this.setState({page})} 573 preferencesKey='application-details'> 574 {data => ( 575 <ApplicationResourceList 576 onNodeClick={fullName => this.selectNode(fullName)} 577 resources={data} 578 nodeMenu={node => 579 AppUtils.renderResourceMenu( 580 {...node, root: node}, 581 application, 582 tree, 583 this.appContext.apis, 584 this.appChanged, 585 () => this.getApplicationActionMenu(application, false) 586 ) 587 } 588 tree={tree} 589 /> 590 )} 591 </Paginate> 592 )) || ( 593 <EmptyState icon='fa fa-search'> 594 <h4>No resources found</h4> 595 <h5>Try to change filter criteria</h5> 596 </EmptyState> 597 )} 598 </div> 599 )} 600 </div> 601 </div> 602 <SlidingPanel isShown={this.state.groupedResources.length > 0} onClose={() => this.closeGroupedNodesPanel()}> 603 <div className='application-details__sliding-panel-pagination-wrap'> 604 <Paginate 605 page={this.state.slidingPanelPage} 606 data={this.state.groupedResources} 607 onPageChange={page => this.setState({slidingPanelPage: page})} 608 preferencesKey='grouped-nodes-details'> 609 {data => ( 610 <ApplicationResourceList 611 onNodeClick={fullName => this.selectNode(fullName)} 612 resources={data} 613 nodeMenu={node => 614 AppUtils.renderResourceMenu({...node, root: node}, application, tree, this.appContext.apis, this.appChanged, () => 615 this.getApplicationActionMenu(application, false) 616 ) 617 } 618 tree={tree} 619 /> 620 )} 621 </Paginate> 622 </div> 623 </SlidingPanel> 624 <SlidingPanel isShown={selectedNode != null || isAppSelected} onClose={() => this.selectNode('')}> 625 <ResourceDetails 626 tree={tree} 627 application={application} 628 isAppSelected={isAppSelected} 629 updateApp={(app: models.Application, query: {validate?: boolean}) => this.updateApp(app, query)} 630 selectedNode={selectedNode} 631 tab={tab} 632 /> 633 </SlidingPanel> 634 <ApplicationSyncPanel 635 application={application} 636 hide={() => AppUtils.showDeploy(null, null, this.appContext.apis)} 637 selectedResource={syncResourceKey} 638 /> 639 <SlidingPanel isShown={this.selectedRollbackDeploymentIndex > -1} onClose={() => this.setRollbackPanelVisible(-1)}> 640 {this.selectedRollbackDeploymentIndex > -1 && ( 641 <ApplicationDeploymentHistory 642 app={application} 643 selectedRollbackDeploymentIndex={this.selectedRollbackDeploymentIndex} 644 rollbackApp={info => this.rollbackApplication(info, application)} 645 selectDeployment={i => this.setRollbackPanelVisible(i)} 646 /> 647 )} 648 </SlidingPanel> 649 <SlidingPanel isShown={this.showOperationState && !!operationState} onClose={() => this.setOperationStatusVisible(false)}> 650 {operationState && <ApplicationOperationState application={application} operationState={operationState} />} 651 </SlidingPanel> 652 <SlidingPanel isShown={this.showConditions && !!conditions} onClose={() => this.setConditionsStatusVisible(false)}> 653 {conditions && <ApplicationConditions conditions={conditions} />} 654 </SlidingPanel> 655 <SlidingPanel isShown={!!this.state.revision} isMiddle={true} onClose={() => this.setState({revision: null})}> 656 {this.state.revision && 657 (source.chart ? ( 658 <DataLoader 659 input={application} 660 load={input => 661 services.applications.revisionChartDetails(input.metadata.name, input.metadata.namespace, this.state.revision) 662 }> 663 {(m: ChartDetails) => ( 664 <div className='white-box' style={{marginTop: '1.5em'}}> 665 <div className='white-box__details'> 666 <div className='row white-box__details-row'> 667 <div className='columns small-3'>Revision:</div> 668 <div className='columns small-9'>{this.state.revision}</div> 669 </div> 670 <div className='row white-box__details-row'> 671 <div className='columns small-3'>Helm Chart:</div> 672 <div className='columns small-9'> 673 {source.chart} 674 {m.home && ( 675 <a 676 title={m.home} 677 onClick={e => { 678 e.stopPropagation(); 679 window.open(m.home); 680 }}> 681 <i className='fa fa-external-link-alt' /> 682 </a> 683 )} 684 </div> 685 </div> 686 {m.description && ( 687 <div className='row white-box__details-row'> 688 <div className='columns small-3'>Description:</div> 689 <div className='columns small-9'>{m.description}</div> 690 </div> 691 )} 692 {m.maintainers && m.maintainers.length > 0 && ( 693 <div className='row white-box__details-row'> 694 <div className='columns small-3'>Maintainers:</div> 695 <div className='columns small-9'>{m.maintainers.join(', ')}</div> 696 </div> 697 )} 698 </div> 699 </div> 700 )} 701 </DataLoader> 702 ) : ( 703 <DataLoader 704 load={() => 705 services.applications.revisionMetadata(application.metadata.name, application.metadata.namespace, this.state.revision) 706 }> 707 {metadata => ( 708 <div className='white-box' style={{marginTop: '1.5em'}}> 709 <div className='white-box__details'> 710 <div className='row white-box__details-row'> 711 <div className='columns small-3'>SHA:</div> 712 <div className='columns small-9'> 713 <Revision repoUrl={source.repoURL} revision={this.state.revision} /> 714 </div> 715 </div> 716 </div> 717 <div className='white-box__details'> 718 <div className='row white-box__details-row'> 719 <div className='columns small-3'>Date:</div> 720 <div className='columns small-9'> 721 <Timestamp date={metadata.date} /> 722 </div> 723 </div> 724 </div> 725 <div className='white-box__details'> 726 <div className='row white-box__details-row'> 727 <div className='columns small-3'>Tags:</div> 728 <div className='columns small-9'> 729 {((metadata.tags || []).length > 0 && metadata.tags.join(', ')) || 'No tags'} 730 </div> 731 </div> 732 </div> 733 <div className='white-box__details'> 734 <div className='row white-box__details-row'> 735 <div className='columns small-3'>Author:</div> 736 <div className='columns small-9'>{metadata.author}</div> 737 </div> 738 </div> 739 <div className='white-box__details'> 740 <div className='row white-box__details-row'> 741 <div className='columns small-3'>Message:</div> 742 <div className='columns small-9' style={{display: 'flex', alignItems: 'center'}}> 743 <div className='application-details__commit-message'>{renderCommitMessage(metadata.message)}</div> 744 </div> 745 </div> 746 </div> 747 </div> 748 )} 749 </DataLoader> 750 ))} 751 </SlidingPanel> 752 <SlidingPanel 753 isShown={this.selectedExtension !== '' && activeExtension != null && activeExtension.flyout != null} 754 onClose={() => this.setExtensionPanelVisible('')}> 755 {this.selectedExtension !== '' && activeExtension && activeExtension.flyout && ( 756 <activeExtension.flyout application={application} tree={tree} /> 757 )} 758 </SlidingPanel> 759 </Page> 760 </div> 761 ); 762 }} 763 </DataLoader> 764 )} 765 </ObservableQuery> 766 ); 767 } 768 769 private getApplicationActionMenu(app: appModels.Application, needOverlapLabelOnNarrowScreen: boolean) { 770 const refreshing = app.metadata.annotations && app.metadata.annotations[appModels.AnnotationRefreshKey]; 771 const fullName = AppUtils.nodeKey({group: 'argoproj.io', kind: app.kind, name: app.metadata.name, namespace: app.metadata.namespace}); 772 const ActionMenuItem = (prop: {actionLabel: string}) => <span className={needOverlapLabelOnNarrowScreen ? 'show-for-large' : ''}>{prop.actionLabel}</span>; 773 const hasMultipleSources = app.spec.sources && app.spec.sources.length > 0; 774 return [ 775 { 776 iconClassName: 'fa fa-info-circle', 777 title: <ActionMenuItem actionLabel='Details' />, 778 action: () => this.selectNode(fullName) 779 }, 780 { 781 iconClassName: 'fa fa-file-medical', 782 title: <ActionMenuItem actionLabel='Diff' />, 783 action: () => this.selectNode(fullName, 0, 'diff'), 784 disabled: app.status.sync.status === appModels.SyncStatuses.Synced 785 }, 786 { 787 iconClassName: 'fa fa-sync', 788 title: <ActionMenuItem actionLabel='Sync' />, 789 action: () => AppUtils.showDeploy('all', null, this.appContext.apis) 790 }, 791 { 792 iconClassName: 'fa fa-info-circle', 793 title: <ActionMenuItem actionLabel='Sync Status' />, 794 action: () => this.setOperationStatusVisible(true), 795 disabled: !app.status.operationState 796 }, 797 { 798 iconClassName: 'fa fa-history', 799 title: hasMultipleSources ? ( 800 <React.Fragment> 801 <ActionMenuItem actionLabel=' History and rollback' /> 802 {helpTip('Rollback is not supported for apps with multiple sources')} 803 </React.Fragment> 804 ) : ( 805 <ActionMenuItem actionLabel='History and rollback' /> 806 ), 807 action: () => { 808 this.setRollbackPanelVisible(0); 809 }, 810 disabled: !app.status.operationState || hasMultipleSources 811 }, 812 { 813 iconClassName: 'fa fa-times-circle', 814 title: <ActionMenuItem actionLabel='Delete' />, 815 action: () => this.deleteApplication() 816 }, 817 { 818 iconClassName: classNames('fa fa-redo', {'status-icon--spin': !!refreshing}), 819 title: ( 820 <React.Fragment> 821 <ActionMenuItem actionLabel='Refresh' />{' '} 822 <DropDownMenu 823 items={[ 824 { 825 title: 'Hard Refresh', 826 action: () => !refreshing && services.applications.get(app.metadata.name, app.metadata.namespace, 'hard') 827 } 828 ]} 829 anchor={() => <i className='fa fa-caret-down' />} 830 /> 831 </React.Fragment> 832 ), 833 disabled: !!refreshing, 834 action: () => { 835 if (!refreshing) { 836 services.applications.get(app.metadata.name, app.metadata.namespace, 'normal'); 837 AppUtils.setAppRefreshing(app); 838 this.appChanged.next(app); 839 } 840 } 841 } 842 ]; 843 } 844 845 private filterTreeNode(node: ResourceTreeNode, filterInput: FilterInput): boolean { 846 const syncStatuses = filterInput.sync.map(item => (item === 'OutOfSync' ? ['OutOfSync', 'Unknown'] : [item])).reduce((first, second) => first.concat(second), []); 847 848 const root = node.root || ({} as ResourceTreeNode); 849 const hook = root && root.hook; 850 if ( 851 (filterInput.name.length === 0 || this.nodeNameMatchesWildcardFilters(node.name, filterInput.name)) && 852 (filterInput.kind.length === 0 || filterInput.kind.indexOf(node.kind) > -1) && 853 // include if node's root sync matches filter 854 (syncStatuses.length === 0 || hook || (root.status && syncStatuses.indexOf(root.status) > -1)) && 855 // include if node or node's root health matches filter 856 (filterInput.health.length === 0 || 857 hook || 858 (root.health && filterInput.health.indexOf(root.health.status) > -1) || 859 (node.health && filterInput.health.indexOf(node.health.status) > -1)) && 860 (filterInput.namespace.length === 0 || filterInput.namespace.includes(node.namespace)) 861 ) { 862 return true; 863 } 864 865 return false; 866 } 867 868 private nodeNameMatchesWildcardFilters(nodeName: string, filterInputNames: string[]): boolean { 869 const regularExpression = new RegExp( 870 filterInputNames 871 // Escape any regex input to ensure only * can be used 872 .map(pattern => '^' + this.escapeRegex(pattern) + '$') 873 // Replace any escaped * with proper regex 874 .map(pattern => pattern.replace(/\\\*/g, '.*')) 875 // Join all filterInputs to a single regular expression 876 .join('|'), 877 'gi' 878 ); 879 return regularExpression.test(nodeName); 880 } 881 882 private escapeRegex(input: string): string { 883 return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 884 } 885 886 private loadAppInfo(name: string, appNamespace: string): Observable<{application: appModels.Application; tree: appModels.ApplicationTree}> { 887 return from(services.applications.get(name, appNamespace)) 888 .pipe( 889 mergeMap(app => { 890 const fallbackTree = { 891 nodes: app.status.resources.map(res => ({...res, parentRefs: [], info: [], resourceVersion: '', uid: ''})), 892 orphanedNodes: [], 893 hosts: [] 894 } as appModels.ApplicationTree; 895 return combineLatest( 896 merge( 897 from([app]), 898 this.appChanged.pipe(filter(item => !!item)), 899 AppUtils.handlePageVisibility(() => 900 services.applications 901 .watch({name, appNamespace}) 902 .pipe( 903 map(watchEvent => { 904 if (watchEvent.type === 'DELETED') { 905 this.onAppDeleted(); 906 } 907 return watchEvent.application; 908 }) 909 ) 910 .pipe(repeat()) 911 .pipe(retryWhen(errors => errors.pipe(delay(500)))) 912 ) 913 ), 914 merge( 915 from([fallbackTree]), 916 services.applications.resourceTree(name, appNamespace).catch(() => fallbackTree), 917 AppUtils.handlePageVisibility(() => 918 services.applications 919 .watchResourceTree(name, appNamespace) 920 .pipe(repeat()) 921 .pipe(retryWhen(errors => errors.pipe(delay(500)))) 922 ) 923 ) 924 ); 925 }) 926 ) 927 .pipe(filter(([application, tree]) => !!application && !!tree)) 928 .pipe(map(([application, tree]) => ({application, tree}))); 929 } 930 931 private onAppDeleted() { 932 this.appContext.apis.notifications.show({type: NotificationType.Success, content: `Application '${this.props.match.params.name}' was deleted`}); 933 this.appContext.apis.navigation.goto('/applications'); 934 } 935 936 private async updateApp(app: appModels.Application, query: {validate?: boolean}) { 937 const latestApp = await services.applications.get(app.metadata.name, app.metadata.namespace); 938 latestApp.metadata.labels = app.metadata.labels; 939 latestApp.metadata.annotations = app.metadata.annotations; 940 latestApp.spec = app.spec; 941 const updatedApp = await services.applications.update(latestApp, query); 942 this.appChanged.next(updatedApp); 943 } 944 945 private groupAppNodesByKey(application: appModels.Application, tree: appModels.ApplicationTree) { 946 const nodeByKey = new Map<string, appModels.ResourceDiff | appModels.ResourceNode | appModels.Application>(); 947 tree.nodes.concat(tree.orphanedNodes || []).forEach(node => nodeByKey.set(AppUtils.nodeKey(node), node)); 948 nodeByKey.set(AppUtils.nodeKey({group: 'argoproj.io', kind: application.kind, name: application.metadata.name, namespace: application.metadata.namespace}), application); 949 return nodeByKey; 950 } 951 952 private getTreeFilter(filterInput: string[]): FilterInput { 953 const name = new Array<string>(); 954 const kind = new Array<string>(); 955 const health = new Array<string>(); 956 const sync = new Array<string>(); 957 const namespace = new Array<string>(); 958 for (const item of filterInput || []) { 959 const [type, val] = item.split(':'); 960 switch (type) { 961 case 'name': 962 name.push(val); 963 break; 964 case 'kind': 965 kind.push(val); 966 break; 967 case 'health': 968 health.push(val); 969 break; 970 case 'sync': 971 sync.push(val); 972 break; 973 case 'namespace': 974 namespace.push(val); 975 break; 976 } 977 } 978 return {kind, health, sync, namespace, name}; 979 } 980 981 private setOperationStatusVisible(isVisible: boolean) { 982 this.appContext.apis.navigation.goto('.', {operation: isVisible}, {replace: true}); 983 } 984 985 private setConditionsStatusVisible(isVisible: boolean) { 986 this.appContext.apis.navigation.goto('.', {conditions: isVisible}, {replace: true}); 987 } 988 989 private setRollbackPanelVisible(selectedDeploymentIndex = 0) { 990 this.appContext.apis.navigation.goto('.', {rollback: selectedDeploymentIndex}, {replace: true}); 991 } 992 993 private setExtensionPanelVisible(selectedExtension = '') { 994 this.appContext.apis.navigation.goto('.', {extension: selectedExtension}, {replace: true}); 995 } 996 997 private selectNode(fullName: string, containerIndex = 0, tab: string = null) { 998 SelectNode(fullName, containerIndex, tab, this.appContext.apis); 999 } 1000 1001 private async rollbackApplication(revisionHistory: appModels.RevisionHistory, application: appModels.Application) { 1002 try { 1003 const needDisableRollback = application.spec.syncPolicy && application.spec.syncPolicy.automated; 1004 let confirmationMessage = `Are you sure you want to rollback application '${this.props.match.params.name}'?`; 1005 if (needDisableRollback) { 1006 confirmationMessage = `Auto-Sync needs to be disabled in order for rollback to occur. 1007 Are you sure you want to disable auto-sync and rollback application '${this.props.match.params.name}'?`; 1008 } 1009 1010 const confirmed = await this.appContext.apis.popup.confirm('Rollback application', confirmationMessage); 1011 if (confirmed) { 1012 if (needDisableRollback) { 1013 const update = JSON.parse(JSON.stringify(application)) as appModels.Application; 1014 update.spec.syncPolicy = {automated: null}; 1015 await services.applications.update(update); 1016 } 1017 await services.applications.rollback(this.props.match.params.name, this.appNamespace, revisionHistory.id); 1018 this.appChanged.next(await services.applications.get(this.props.match.params.name, this.appNamespace)); 1019 this.setRollbackPanelVisible(-1); 1020 } 1021 } catch (e) { 1022 this.appContext.apis.notifications.show({ 1023 content: <ErrorNotification title='Unable to rollback application' e={e} />, 1024 type: NotificationType.Error 1025 }); 1026 } 1027 } 1028 1029 private get appContext(): AppContext { 1030 return this.context as AppContext; 1031 } 1032 1033 private async deleteApplication() { 1034 await AppUtils.deleteApplication(this.props.match.params.name, this.appNamespace, this.appContext.apis); 1035 } 1036 } 1037 1038 const ExtensionView = (props: {extension: AppViewExtension; application: models.Application; tree: models.ApplicationTree}) => { 1039 const {extension, application, tree} = props; 1040 return <extension.component application={application} tree={tree} />; 1041 };