github.com/argoproj/argo-cd/v3@v3.2.1/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, CheckboxField} 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, getAppCurrentVersion, urlPattern} from '../utils'; 28 import {ChartDetails, OCIMetadata, 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 {TopBarActionMenuExt, AppViewExtension, StatusPanelExtension} from '../../../shared/services/extensions-service'; 34 import {ApplicationHydrateOperationState} from '../application-hydrate-operation-state/application-hydrate-operation-state'; 35 36 interface ApplicationDetailsState { 37 page: number; 38 revision?: string; // Which type of revision panelto show SYNC_STATUS_REVISION or OPERATION_STATE_REVISION 39 groupedResources?: ResourceStatus[]; 40 slidingPanelPage?: number; 41 filteredGraph?: any[]; 42 truncateNameOnRight?: boolean; 43 showFullNodeName?: boolean; 44 collapsedNodes?: string[]; 45 extensions?: AppViewExtension[]; 46 extensionsMap?: {[key: string]: AppViewExtension}; 47 statusExtensions?: StatusPanelExtension[]; 48 statusExtensionsMap?: {[key: string]: StatusPanelExtension}; 49 topBarActionMenuExts?: TopBarActionMenuExt[]; 50 topBarActionMenuExtsMap?: {[key: string]: TopBarActionMenuExt}; 51 } 52 53 interface FilterInput { 54 name: string[]; 55 kind: string[]; 56 health: string[]; 57 sync: string[]; 58 namespace: string[]; 59 } 60 61 const ApplicationDetailsFilters = (props: FiltersProps) => { 62 const sidebarTarget = useSidebarTarget(); 63 return ReactDOM.createPortal(<Filters {...props} />, sidebarTarget?.current); 64 }; 65 66 export const NodeInfo = (node?: string): {key: string; container: number} => { 67 const nodeContainer = {key: '', container: 0}; 68 if (node) { 69 const parts = node.split('/'); 70 nodeContainer.key = parts.slice(0, 4).join('/'); 71 nodeContainer.container = parseInt(parts[4] || '0', 10); 72 } 73 return nodeContainer; 74 }; 75 76 export const SelectNode = (fullName: string, containerIndex = 0, tab: string = null, appContext: ContextApis) => { 77 const node = fullName ? `${fullName}/${containerIndex}` : null; 78 appContext.navigation.goto('.', {node, tab}, {replace: true}); 79 }; 80 81 export class ApplicationDetails extends React.Component<RouteComponentProps<{appnamespace: string; name: string}>, ApplicationDetailsState> { 82 public static contextTypes = { 83 apis: PropTypes.object 84 }; 85 86 private appChanged = new BehaviorSubject<appModels.Application>(null); 87 88 constructor(props: RouteComponentProps<{appnamespace: string; name: string}>) { 89 super(props); 90 this.state = { 91 page: 0, 92 groupedResources: [], 93 slidingPanelPage: 0, 94 filteredGraph: [], 95 truncateNameOnRight: false, 96 showFullNodeName: false, 97 collapsedNodes: [], 98 ...this.getExtensionsState() 99 }; 100 } 101 102 public componentDidMount() { 103 services.extensions.addEventListener('resource', this.onExtensionsUpdate); 104 services.extensions.addEventListener('appView', this.onExtensionsUpdate); 105 services.extensions.addEventListener('statusPanel', this.onExtensionsUpdate); 106 services.extensions.addEventListener('topBar', this.onExtensionsUpdate); 107 } 108 109 public componentWillUnmount() { 110 services.extensions.removeEventListener('resource', this.onExtensionsUpdate); 111 services.extensions.removeEventListener('appView', this.onExtensionsUpdate); 112 services.extensions.removeEventListener('statusPanel', this.onExtensionsUpdate); 113 services.extensions.removeEventListener('topBar', this.onExtensionsUpdate); 114 } 115 116 private getAppNamespace() { 117 if (typeof this.props.match.params.appnamespace === 'undefined') { 118 return ''; 119 } 120 return this.props.match.params.appnamespace; 121 } 122 123 private onExtensionsUpdate = () => { 124 this.setState({...this.state, ...this.getExtensionsState()}); 125 }; 126 127 private getExtensionsState = () => { 128 const extensions = services.extensions.getAppViewExtensions(); 129 const extensionsMap: {[key: string]: AppViewExtension} = {}; 130 extensions.forEach(ext => { 131 extensionsMap[ext.title] = ext; 132 }); 133 const statusExtensions = services.extensions.getStatusPanelExtensions(); 134 const statusExtensionsMap: {[key: string]: StatusPanelExtension} = {}; 135 statusExtensions.forEach(ext => { 136 statusExtensionsMap[ext.id] = ext; 137 }); 138 const topBarActionMenuExts = services.extensions.getActionMenuExtensions(); 139 const topBarActionMenuExtsMap: {[key: string]: TopBarActionMenuExt} = {}; 140 topBarActionMenuExts.forEach(ext => { 141 topBarActionMenuExtsMap[ext.id] = ext; 142 }); 143 return {extensions, extensionsMap, statusExtensions, statusExtensionsMap, topBarActionMenuExts, topBarActionMenuExtsMap}; 144 }; 145 146 private get showHydrateOperationState() { 147 return new URLSearchParams(this.props.history.location.search).get('hydrateOperation') === 'true'; 148 } 149 150 private get showOperationState() { 151 return new URLSearchParams(this.props.history.location.search).get('operation') === 'true'; 152 } 153 154 private setNodeExpansion(node: string, isExpanded: boolean) { 155 const index = this.state.collapsedNodes.indexOf(node); 156 if (isExpanded && index >= 0) { 157 this.state.collapsedNodes.splice(index, 1); 158 const updatedNodes = this.state.collapsedNodes.slice(); 159 this.setState({collapsedNodes: updatedNodes}); 160 } else if (!isExpanded && index < 0) { 161 const updatedNodes = this.state.collapsedNodes.slice(); 162 updatedNodes.push(node); 163 this.setState({collapsedNodes: updatedNodes}); 164 } 165 } 166 167 private getNodeExpansion(node: string): boolean { 168 return this.state.collapsedNodes.indexOf(node) < 0; 169 } 170 171 private get showConditions() { 172 return new URLSearchParams(this.props.history.location.search).get('conditions') === 'true'; 173 } 174 175 private get selectedRollbackDeploymentIndex() { 176 return parseInt(new URLSearchParams(this.props.history.location.search).get('rollback'), 10); 177 } 178 179 private get selectedNodeInfo() { 180 return NodeInfo(new URLSearchParams(this.props.history.location.search).get('node')); 181 } 182 183 private get selectedNodeKey() { 184 const nodeContainer = this.selectedNodeInfo; 185 return nodeContainer.key; 186 } 187 188 private get selectedExtension() { 189 return new URLSearchParams(this.props.history.location.search).get('extension'); 190 } 191 192 private closeGroupedNodesPanel() { 193 this.setState({groupedResources: []}); 194 this.setState({slidingPanelPage: 0}); 195 } 196 197 private toggleCompactView(appName: string, pref: AppDetailsPreferences) { 198 pref.userHelpTipMsgs = pref.userHelpTipMsgs.map(usrMsg => (usrMsg.appName === appName && usrMsg.msgKey === 'groupNodes' ? {...usrMsg, display: true} : usrMsg)); 199 services.viewPreferences.updatePreferences({appDetails: {...pref, groupNodes: !pref.groupNodes}}); 200 } 201 202 private getPageTitle(view: string) { 203 const {Tree, Pods, Network, List} = AppsDetailsViewKey; 204 switch (view) { 205 case Tree: 206 return 'Application Details Tree'; 207 case Network: 208 return 'Application Details Network'; 209 case Pods: 210 return 'Application Details Pods'; 211 case List: 212 return 'Application Details List'; 213 } 214 return ''; 215 } 216 217 private getContent(application: models.Application, source: models.ApplicationSource, revisions: string[], revision: string) { 218 const renderCommitMessage = (message: string) => 219 message.split(/\s/).map(part => 220 urlPattern.test(part) ? ( 221 <a href={part} target='_blank' rel='noopener noreferrer' style={{overflowWrap: 'anywhere', wordBreak: 'break-word'}}> 222 {part}{' '} 223 </a> 224 ) : ( 225 part + ' ' 226 ) 227 ); 228 229 const getContentForOci = ( 230 aRevision: string, 231 aSourceIndex: number | null, 232 aVersionId: number | null, 233 indx: number, 234 aSource: models.ApplicationSource, 235 sourceHeader?: JSX.Element 236 ) => { 237 const showChartNonMetadataInfo = (aRevision: string, aRepoUrl: string) => { 238 return ( 239 <> 240 <div className='row white-box__details-row'> 241 <div className='columns small-3'>Revision:</div> 242 <div className='columns small-9'>{aRevision}</div> 243 </div> 244 <div className='row white-box__details-row'> 245 <div className='columns small-3'>OCI Image:</div> 246 <div className='columns small-9'>{aRepoUrl}</div> 247 </div> 248 </> 249 ); 250 }; 251 return ( 252 <DataLoader 253 key={indx} 254 input={application} 255 load={input => services.applications.ociMetadata(input.metadata.name, input.metadata.namespace, aRevision, aSourceIndex, aVersionId)}> 256 {(m: OCIMetadata) => { 257 return m ? ( 258 <div className='white-box' style={{marginTop: '1.5em'}}> 259 {sourceHeader && sourceHeader} 260 <div className='white-box__details'> 261 {showChartNonMetadataInfo(aRevision, aSource.repoURL)} 262 {m.description && ( 263 <div className='row white-box__details-row'> 264 <div className='columns small-3'>Description:</div> 265 <div className='columns small-9'>{m.description}</div> 266 </div> 267 )} 268 {m.authors && m.authors.length > 0 && ( 269 <div className='row white-box__details-row'> 270 <div className='columns small-3'>Maintainers:</div> 271 <div className='columns small-9'>{m.authors}</div> 272 </div> 273 )} 274 </div> 275 </div> 276 ) : ( 277 <div key={indx} className='white-box' style={{marginTop: '1.5em'}}> 278 <div>Source {indx + 1}</div> 279 <div className='white-box__details'> 280 {showChartNonMetadataInfo(aRevision, aSource.repoURL)} 281 <div className='row white-box__details-row'> 282 <div className='columns small-3'>Helm Chart:</div> 283 <div className='columns small-9'> 284 {aSource.chart} 285 { 286 <a 287 title={sources[indx].chart} 288 onClick={e => { 289 e.stopPropagation(); 290 window.open(aSource.repoURL); 291 }}> 292 <i className='fa fa-external-link-alt' /> 293 </a> 294 } 295 </div> 296 </div> 297 </div> 298 </div> 299 ); 300 }} 301 </DataLoader> 302 ); 303 }; 304 305 const getContentForChart = ( 306 aRevision: string, 307 aSourceIndex: number | null, 308 aVersionId: number | null, 309 indx: number, 310 aSource: models.ApplicationSource, 311 sourceHeader?: JSX.Element 312 ) => { 313 const showChartNonMetadataInfo = (aRevision: string, aRepoUrl: string) => { 314 return ( 315 <> 316 <div className='row white-box__details-row'> 317 <div className='columns small-3'>Revision:</div> 318 <div className='columns small-9'>{aRevision}</div> 319 </div> 320 <div className='row white-box__details-row'> 321 <div className='columns small-3'>Chart Source:</div> 322 <div className='columns small-9'>{aRepoUrl}</div> 323 </div> 324 </> 325 ); 326 }; 327 return ( 328 <DataLoader 329 key={indx} 330 input={application} 331 load={input => services.applications.revisionChartDetails(input.metadata.name, input.metadata.namespace, aRevision, aSourceIndex, aVersionId)}> 332 {(m: ChartDetails) => { 333 return m ? ( 334 <div className='white-box' style={{marginTop: '1.5em'}}> 335 {sourceHeader && sourceHeader} 336 <div className='white-box__details'> 337 {showChartNonMetadataInfo(aRevision, aSource.repoURL)} 338 <div className='row white-box__details-row'> 339 <div className='columns small-3'>Helm Chart:</div> 340 <div className='columns small-9'> 341 {aSource.chart} 342 {m.home && ( 343 <a 344 title={m.home} 345 onClick={e => { 346 e.stopPropagation(); 347 window.open(m.home); 348 }}> 349 <i className='fa fa-external-link-alt' /> 350 </a> 351 )} 352 </div> 353 </div> 354 {m.description && ( 355 <div className='row white-box__details-row'> 356 <div className='columns small-3'>Description:</div> 357 <div className='columns small-9'>{m.description}</div> 358 </div> 359 )} 360 {m.maintainers && m.maintainers.length > 0 && ( 361 <div className='row white-box__details-row'> 362 <div className='columns small-3'>Maintainers:</div> 363 <div className='columns small-9'>{m.maintainers.join(', ')}</div> 364 </div> 365 )} 366 </div> 367 </div> 368 ) : ( 369 <div key={indx} className='white-box' style={{marginTop: '1.5em'}}> 370 <div>Source {indx + 1}</div> 371 <div className='white-box__details'> 372 {showChartNonMetadataInfo(aRevision, aSource.repoURL)} 373 <div className='row white-box__details-row'> 374 <div className='columns small-3'>Helm Chart:</div> 375 <div className='columns small-9'> 376 {aSource.chart} 377 { 378 <a 379 title={sources[indx].chart} 380 onClick={e => { 381 e.stopPropagation(); 382 window.open(aSource.repoURL); 383 }}> 384 <i className='fa fa-external-link-alt' /> 385 </a> 386 } 387 </div> 388 </div> 389 </div> 390 </div> 391 ); 392 }} 393 </DataLoader> 394 ); 395 }; 396 397 const getContentForNonChart = ( 398 aRevision: string, 399 aSourceIndex: number, 400 aVersionId: number | null, 401 indx: number, 402 aSource: models.ApplicationSource, 403 sourceHeader?: JSX.Element 404 ) => { 405 const showNonMetadataInfo = (aSource: models.ApplicationSource, aRevision: string) => { 406 return ( 407 <> 408 <div className='white-box__details'> 409 <div className='row white-box__details-row'> 410 <div className='columns small-3'>SHA:</div> 411 <div className='columns small-9'> 412 <Revision repoUrl={aSource.repoURL} revision={aRevision} /> 413 </div> 414 </div> 415 </div> 416 <div className='white-box__details'> 417 <div className='row white-box__details-row'> 418 <div className='columns small-3'>Source:</div> 419 <div className='columns small-9'>{aSource.repoURL}</div> 420 </div> 421 </div> 422 </> 423 ); 424 }; 425 return ( 426 <DataLoader 427 key={indx} 428 load={() => services.applications.revisionMetadata(application.metadata.name, application.metadata.namespace, aRevision, aSourceIndex, aVersionId)}> 429 {metadata => 430 metadata ? ( 431 <div key={indx} className='white-box' style={{marginTop: '1.5em'}}> 432 {sourceHeader && sourceHeader} 433 {showNonMetadataInfo(aSource, aRevision)} 434 <div className='white-box__details'> 435 <div className='row white-box__details-row'> 436 <div className='columns small-3'>Date:</div> 437 <div className='columns small-9'> 438 <Timestamp date={metadata.date} /> 439 </div> 440 </div> 441 </div> 442 <div className='white-box__details'> 443 <div className='row white-box__details-row'> 444 <div className='columns small-3'>Tags:</div> 445 <div className='columns small-9'>{((metadata.tags || []).length > 0 && metadata.tags.join(', ')) || 'No tags'}</div> 446 </div> 447 </div> 448 <div className='white-box__details'> 449 <div className='row white-box__details-row'> 450 <div className='columns small-3'>Author:</div> 451 <div className='columns small-9'>{metadata.author}</div> 452 </div> 453 </div> 454 <div className='white-box__details'> 455 <div className='row white-box__details-row'> 456 <div className='columns small-3'>Message:</div> 457 <div className='columns small-9' style={{display: 'flex', alignItems: 'center'}}> 458 <div className='application-details__commit-message'>{renderCommitMessage(metadata.message)}</div> 459 </div> 460 </div> 461 </div> 462 </div> 463 ) : ( 464 <div key={indx} className='white-box' style={{marginTop: '1.5em'}}> 465 <div>Source {indx + 1}</div> 466 {showNonMetadataInfo(aSource, aRevision)} 467 </div> 468 ) 469 } 470 </DataLoader> 471 ); 472 }; 473 const cont: JSX.Element[] = []; 474 const sources: models.ApplicationSource[] = application.spec.sources; 475 if (sources?.length > 0 && revisions) { 476 revisions.forEach((rev, indx) => { 477 if (sources[indx].repoURL.startsWith('oci://')) { 478 cont.push(getContentForOci(rev, indx, getAppCurrentVersion(application), indx, sources[indx], <div>Source {indx + 1}</div>)); 479 } else if (sources[indx].chart) { 480 cont.push(getContentForChart(rev, indx, getAppCurrentVersion(application), indx, sources[indx], <div>Source {indx + 1}</div>)); 481 } else { 482 cont.push(getContentForNonChart(rev, indx, getAppCurrentVersion(application), indx, sources[indx], <div>Source {indx + 1}</div>)); 483 } 484 }); 485 return <>{cont}</>; 486 } else if (application.spec.source) { 487 if (source.repoURL.startsWith('oci://')) { 488 cont.push(getContentForOci(revision, null, getAppCurrentVersion(application), 0, source)); 489 } else if (source.chart) { 490 cont.push(getContentForChart(revision, null, null, 0, source)); 491 } else { 492 cont.push(getContentForNonChart(revision, null, getAppCurrentVersion(application), 0, source)); 493 } 494 return <>{cont}</>; 495 } else { 496 return ( 497 <div className='white-box' style={{marginTop: '1.5em'}}> 498 <div className='white-box__details'> 499 <div className='row white-box__details-row'> 500 <div className='columns small-9'>No other information available</div> 501 </div> 502 </div> 503 </div> 504 ); 505 } 506 } 507 508 public render() { 509 return ( 510 <ObservableQuery> 511 {q => ( 512 <DataLoader 513 errorRenderer={error => <Page title='Application Details'>{error}</Page>} 514 loadingRenderer={() => <Page title='Application Details'>Loading...</Page>} 515 input={this.props.match.params.name} 516 load={name => 517 combineLatest([this.loadAppInfo(name, this.getAppNamespace()), services.viewPreferences.getPreferences(), q]).pipe( 518 map(items => { 519 const application = items[0].application; 520 const pref = items[1].appDetails; 521 const params = items[2]; 522 if (params.get('resource') != null) { 523 pref.resourceFilter = params 524 .get('resource') 525 .split(',') 526 .filter(item => !!item); 527 } 528 if (params.get('view') != null) { 529 pref.view = params.get('view') as AppsDetailsViewType; 530 } else { 531 const appDefaultView = (application.metadata && 532 application.metadata.annotations && 533 application.metadata.annotations[appModels.AnnotationDefaultView]) as AppsDetailsViewType; 534 if (appDefaultView != null) { 535 pref.view = appDefaultView; 536 } 537 } 538 if (params.get('orphaned') != null) { 539 pref.orphanedResources = params.get('orphaned') === 'true'; 540 } 541 if (params.get('podSortMode') != null) { 542 pref.podView.sortMode = params.get('podSortMode') as PodGroupType; 543 } else { 544 const appDefaultPodSort = (application.metadata && 545 application.metadata.annotations && 546 application.metadata.annotations[appModels.AnnotationDefaultPodSort]) as PodGroupType; 547 if (appDefaultPodSort != null) { 548 pref.podView.sortMode = appDefaultPodSort; 549 } 550 } 551 return {...items[0], pref}; 552 }) 553 ) 554 }> 555 {({application, tree, pref}: {application: appModels.Application; tree: appModels.ApplicationTree; pref: AppDetailsPreferences}) => { 556 tree.nodes = tree.nodes || []; 557 const treeFilter = this.getTreeFilter(pref.resourceFilter); 558 const setFilter = (items: string[]) => { 559 this.appContext.apis.navigation.goto('.', {resource: items.join(',')}, {replace: true}); 560 services.viewPreferences.updatePreferences({appDetails: {...pref, resourceFilter: items}}); 561 }; 562 const clearFilter = () => setFilter([]); 563 const refreshing = application.metadata.annotations && application.metadata.annotations[appModels.AnnotationRefreshKey]; 564 const appNodesByName = this.groupAppNodesByKey(application, tree); 565 const selectedItem = (this.selectedNodeKey && appNodesByName.get(this.selectedNodeKey)) || null; 566 const isAppSelected = selectedItem === application; 567 const selectedNode = !isAppSelected && (selectedItem as appModels.ResourceNode); 568 const operationState = application.status.operationState; 569 const hydrateOperationState = application.status.sourceHydrator.currentOperation; 570 const conditions = application.status.conditions || []; 571 const syncResourceKey = new URLSearchParams(this.props.history.location.search).get('deploy'); 572 const tab = new URLSearchParams(this.props.history.location.search).get('tab'); 573 const source = getAppDefaultSource(application); 574 const showToolTip = pref?.userHelpTipMsgs.find(usrMsg => usrMsg.appName === application.metadata.name); 575 const resourceNodes = (): any[] => { 576 const statusByKey = new Map<string, models.ResourceStatus>(); 577 application.status.resources.forEach(res => statusByKey.set(AppUtils.nodeKey(res), res)); 578 const resources = new Map<string, any>(); 579 tree.nodes 580 .map(node => ({...node, orphaned: false})) 581 .concat(((pref.orphanedResources && tree.orphanedNodes) || []).map(node => ({...node, orphaned: true}))) 582 .forEach(node => { 583 const resource: any = {...node}; 584 resource.uid = node.uid; 585 const status = statusByKey.get(AppUtils.nodeKey(node)); 586 if (status) { 587 resource.health = status.health; 588 resource.status = status.status; 589 resource.hook = status.hook; 590 resource.syncWave = status.syncWave; 591 resource.requiresPruning = status.requiresPruning; 592 } 593 resources.set(node.uid || AppUtils.nodeKey(node), resource); 594 }); 595 const resourcesRef = Array.from(resources.values()); 596 return resourcesRef; 597 }; 598 599 const filteredRes = resourceNodes().filter(res => { 600 const resNode: ResourceTreeNode = {...res, root: null, info: null, parentRefs: [], resourceVersion: '', uid: ''}; 601 resNode.root = resNode; 602 return this.filterTreeNode(resNode, treeFilter); 603 }); 604 const openGroupNodeDetails = (groupdedNodeIds: string[]) => { 605 const resources = resourceNodes(); 606 this.setState({ 607 groupedResources: groupdedNodeIds 608 ? resources.filter(res => groupdedNodeIds.includes(res.uid) || groupdedNodeIds.includes(AppUtils.nodeKey(res))) 609 : [] 610 }); 611 }; 612 const {Tree, Pods, Network, List} = AppsDetailsViewKey; 613 const zoomNum = (pref.zoom * 100).toFixed(0); 614 const setZoom = (s: number) => { 615 let targetZoom: number = pref.zoom + s; 616 if (targetZoom <= 0.05) { 617 targetZoom = 0.1; 618 } else if (targetZoom > 2.0) { 619 targetZoom = 2.0; 620 } 621 services.viewPreferences.updatePreferences({appDetails: {...pref, zoom: targetZoom}}); 622 }; 623 const setFilterGraph = (filterGraph: any[]) => { 624 this.setState({filteredGraph: filterGraph}); 625 }; 626 const setShowCompactNodes = (showCompactView: boolean) => { 627 services.viewPreferences.updatePreferences({appDetails: {...pref, groupNodes: showCompactView}}); 628 }; 629 const updateHelpTipState = (usrHelpTip: models.UserMessages) => { 630 const existingIndex = pref.userHelpTipMsgs.findIndex(msg => msg.appName === usrHelpTip.appName && msg.msgKey === usrHelpTip.msgKey); 631 if (existingIndex !== -1) { 632 pref.userHelpTipMsgs[existingIndex] = usrHelpTip; 633 } else { 634 (pref.userHelpTipMsgs || []).push(usrHelpTip); 635 } 636 }; 637 const toggleNodeName = () => { 638 this.setState({showFullNodeName: !this.state.showFullNodeName}); 639 }; 640 const toggleNameDirection = () => { 641 this.setState({truncateNameOnRight: !this.state.truncateNameOnRight}); 642 }; 643 const expandAll = () => { 644 this.setState({collapsedNodes: []}); 645 }; 646 const collapseAll = () => { 647 const nodes = new Array<ResourceTreeNode>(); 648 tree.nodes 649 .map(node => ({...node, orphaned: false})) 650 .concat((tree.orphanedNodes || []).map(node => ({...node, orphaned: true}))) 651 .forEach(node => { 652 const resourceNode: ResourceTreeNode = {...node}; 653 nodes.push(resourceNode); 654 }); 655 const collapsedNodesList = this.state.collapsedNodes.slice(); 656 if (pref.view === 'network') { 657 const networkNodes = nodes.filter(node => node.networkingInfo); 658 networkNodes.forEach(parent => { 659 const parentId = parent.uid; 660 if (collapsedNodesList.indexOf(parentId) < 0) { 661 collapsedNodesList.push(parentId); 662 } 663 }); 664 this.setState({collapsedNodes: collapsedNodesList}); 665 } else { 666 const managedKeys = new Set(application.status.resources.map(AppUtils.nodeKey)); 667 nodes.forEach(node => { 668 if (!((node.parentRefs || []).length === 0 || managedKeys.has(AppUtils.nodeKey(node)))) { 669 node.parentRefs.forEach(parent => { 670 const parentId = parent.uid; 671 if (collapsedNodesList.indexOf(parentId) < 0) { 672 collapsedNodesList.push(parentId); 673 } 674 }); 675 } 676 }); 677 collapsedNodesList.push(application.kind + '-' + application.metadata.namespace + '-' + application.metadata.name); 678 this.setState({collapsedNodes: collapsedNodesList}); 679 } 680 }; 681 const appFullName = AppUtils.nodeKey({ 682 group: 'argoproj.io', 683 kind: application.kind, 684 name: application.metadata.name, 685 namespace: application.metadata.namespace 686 }); 687 688 const activeStatusExt = this.state.statusExtensionsMap[this.selectedExtension]; 689 const activeTopBarActionMenuExt = this.state.topBarActionMenuExtsMap[this.selectedExtension]; 690 691 return ( 692 <div className={`application-details ${this.props.match.params.name}`}> 693 <Page 694 title={this.props.match.params.name + ' - ' + this.getPageTitle(pref.view)} 695 useTitleOnly={true} 696 topBarTitle={this.getPageTitle(pref.view)} 697 toolbar={{ 698 breadcrumbs: [ 699 {title: 'Applications', path: '/applications'}, 700 {title: <ApplicationsDetailsAppDropdown appName={this.props.match.params.name} />} 701 ], 702 actionMenu: { 703 items: [ 704 ...this.getApplicationActionMenu(application, true), 705 ...(this.state.topBarActionMenuExts 706 ?.filter(ext => ext.shouldDisplay?.(application)) 707 .map(ext => this.renderActionMenuItem(ext, tree, application, this.setExtensionPanelVisible)) || []) 708 ] 709 }, 710 tools: ( 711 <React.Fragment key='app-list-tools'> 712 <div className='application-details__view-type'> 713 <i 714 className={classNames('fa fa-sitemap', {selected: pref.view === Tree})} 715 title='Tree' 716 onClick={() => { 717 this.appContext.apis.navigation.goto('.', {view: Tree}); 718 services.viewPreferences.updatePreferences({appDetails: {...pref, view: Tree}}); 719 }} 720 /> 721 <i 722 className={classNames('fa fa-th', {selected: pref.view === Pods})} 723 title='Pods' 724 onClick={() => { 725 this.appContext.apis.navigation.goto('.', {view: Pods}); 726 services.viewPreferences.updatePreferences({appDetails: {...pref, view: Pods}}); 727 }} 728 /> 729 <i 730 className={classNames('fa fa-network-wired', {selected: pref.view === Network})} 731 title='Network' 732 onClick={() => { 733 this.appContext.apis.navigation.goto('.', {view: Network}); 734 services.viewPreferences.updatePreferences({appDetails: {...pref, view: Network}}); 735 }} 736 /> 737 <i 738 className={classNames('fa fa-th-list', {selected: pref.view === List})} 739 title='List' 740 onClick={() => { 741 this.appContext.apis.navigation.goto('.', {view: List}); 742 services.viewPreferences.updatePreferences({appDetails: {...pref, view: List}}); 743 }} 744 /> 745 {this.state.extensions && 746 (this.state.extensions || []).map(ext => ( 747 <i 748 key={ext.title} 749 className={classNames(`fa ${ext.icon}`, {selected: pref.view === ext.title})} 750 title={ext.title} 751 onClick={() => { 752 this.appContext.apis.navigation.goto('.', {view: ext.title}); 753 services.viewPreferences.updatePreferences({appDetails: {...pref, view: ext.title}}); 754 }} 755 /> 756 ))} 757 </div> 758 </React.Fragment> 759 ) 760 }}> 761 <div className='application-details__wrapper'> 762 <div className='application-details__status-panel'> 763 <ApplicationStatusPanel 764 application={application} 765 showDiff={() => this.selectNode(appFullName, 0, 'diff')} 766 showOperation={() => this.setOperationStatusVisible(true)} 767 showHydrateOperation={() => this.setHydrateOperationStatusVisible(true)} 768 showConditions={() => this.setConditionsStatusVisible(true)} 769 showExtension={id => this.setExtensionPanelVisible(id)} 770 showMetadataInfo={revision => this.setState({...this.state, revision})} 771 /> 772 </div> 773 <div className='application-details__tree'> 774 {refreshing && <p className='application-details__refreshing-label'>Refreshing</p>} 775 {((pref.view === 'tree' || pref.view === 'network') && ( 776 <> 777 <DataLoader load={() => services.viewPreferences.getPreferences()}> 778 {viewPref => ( 779 <ApplicationDetailsFilters 780 pref={pref} 781 tree={tree} 782 onSetFilter={setFilter} 783 onClearFilter={clearFilter} 784 collapsed={viewPref.hideSidebar} 785 resourceNodes={this.state.filteredGraph} 786 /> 787 )} 788 </DataLoader> 789 <div className='graph-options-panel'> 790 <a 791 className={`group-nodes-button`} 792 onClick={() => { 793 toggleNameDirection(); 794 }} 795 title={this.state.truncateNameOnRight ? 'Truncate resource name right' : 'Truncate resource name left'}> 796 <i 797 className={classNames({ 798 'fa fa-align-right': this.state.truncateNameOnRight, 799 'fa fa-align-left': !this.state.truncateNameOnRight 800 })} 801 /> 802 </a> 803 <a 804 className={`group-nodes-button`} 805 onClick={() => { 806 toggleNodeName(); 807 }} 808 title={this.state.showFullNodeName ? 'Show wrapped resource name' : 'Show full resource name'}> 809 <i 810 className={classNames({ 811 'fa fa-expand': this.state.showFullNodeName, 812 'fa fa-compress': !this.state.showFullNodeName 813 })} 814 /> 815 </a> 816 {(pref.view === 'tree' || pref.view === 'network') && ( 817 <Tooltip 818 content={AppUtils.userMsgsList[showToolTip?.msgKey] || 'Group Nodes'} 819 visible={pref.groupNodes && showToolTip !== undefined && !showToolTip?.display} 820 duration={showToolTip?.duration} 821 zIndex={1}> 822 <a 823 className={`group-nodes-button group-nodes-button${!pref.groupNodes ? '' : '-on'}`} 824 title={pref.view === 'tree' ? 'Group Nodes' : 'Collapse Pods'} 825 onClick={() => this.toggleCompactView(application.metadata.name, pref)}> 826 <i className={classNames('fa fa-object-group fa-fw')} /> 827 </a> 828 </Tooltip> 829 )} 830 <span className={`separator`} /> 831 <a className={`group-nodes-button`} onClick={() => expandAll()} title='Expand all child nodes of all parent nodes'> 832 <i className='fa fa-plus fa-fw' /> 833 </a> 834 <a className={`group-nodes-button`} onClick={() => collapseAll()} title='Collapse all child nodes of all parent nodes'> 835 <i className='fa fa-minus fa-fw' /> 836 </a> 837 <span className={`separator`} /> 838 <span> 839 <a className={`group-nodes-button`} onClick={() => setZoom(0.1)} title='Zoom in'> 840 <i className='fa fa-search-plus fa-fw' /> 841 </a> 842 <a className={`group-nodes-button`} onClick={() => setZoom(-0.1)} title='Zoom out'> 843 <i className='fa fa-search-minus fa-fw' /> 844 </a> 845 <div className={`zoom-value`}>{zoomNum}%</div> 846 </span> 847 </div> 848 <ApplicationResourceTree 849 nodeFilter={node => this.filterTreeNode(node, treeFilter)} 850 selectedNodeFullName={this.selectedNodeKey} 851 onNodeClick={fullName => this.selectNode(fullName)} 852 nodeMenu={node => 853 AppUtils.renderResourceMenu(node, application, tree, this.appContext.apis, this.appChanged, () => 854 this.getApplicationActionMenu(application, false) 855 ) 856 } 857 showCompactNodes={pref.groupNodes} 858 userMsgs={pref.userHelpTipMsgs} 859 tree={tree} 860 app={application} 861 showOrphanedResources={pref.orphanedResources} 862 useNetworkingHierarchy={pref.view === 'network'} 863 onClearFilter={clearFilter} 864 onGroupdNodeClick={groupdedNodeIds => openGroupNodeDetails(groupdedNodeIds)} 865 zoom={pref.zoom} 866 podGroupCount={pref.podGroupCount} 867 appContext={this.appContext} 868 nameDirection={this.state.truncateNameOnRight} 869 nameWrap={this.state.showFullNodeName} 870 filters={pref.resourceFilter} 871 setTreeFilterGraph={setFilterGraph} 872 updateUsrHelpTipMsgs={updateHelpTipState} 873 setShowCompactNodes={setShowCompactNodes} 874 setNodeExpansion={(node, isExpanded) => this.setNodeExpansion(node, isExpanded)} 875 getNodeExpansion={node => this.getNodeExpansion(node)} 876 /> 877 </> 878 )) || 879 (pref.view === 'pods' && ( 880 <PodView 881 tree={tree} 882 app={application} 883 onItemClick={fullName => this.selectNode(fullName)} 884 nodeMenu={node => 885 AppUtils.renderResourceMenu(node, application, tree, this.appContext.apis, this.appChanged, () => 886 this.getApplicationActionMenu(application, false) 887 ) 888 } 889 quickStarts={node => AppUtils.renderResourceButtons(node, application, tree, this.appContext.apis, this.appChanged)} 890 /> 891 )) || 892 (this.state.extensionsMap[pref.view] != null && ( 893 <ExtensionView extension={this.state.extensionsMap[pref.view]} application={application} tree={tree} /> 894 )) || ( 895 <div> 896 <DataLoader load={() => services.viewPreferences.getPreferences()}> 897 {viewPref => ( 898 <ApplicationDetailsFilters 899 pref={pref} 900 tree={tree} 901 onSetFilter={setFilter} 902 onClearFilter={clearFilter} 903 collapsed={viewPref.hideSidebar} 904 resourceNodes={filteredRes} 905 /> 906 )} 907 </DataLoader> 908 {(filteredRes.length > 0 && ( 909 <Paginate 910 page={this.state.page} 911 data={filteredRes} 912 onPageChange={page => this.setState({page})} 913 preferencesKey='application-details'> 914 {data => ( 915 <ApplicationResourceList 916 pref={pref} 917 onNodeClick={fullName => this.selectNode(fullName)} 918 resources={data} 919 nodeMenu={node => 920 AppUtils.renderResourceMenu(node, application, tree, this.appContext.apis, this.appChanged, () => 921 this.getApplicationActionMenu(application, false) 922 ) 923 } 924 tree={tree} 925 /> 926 )} 927 </Paginate> 928 )) || ( 929 <EmptyState icon='fa fa-search'> 930 <h4>No resources found</h4> 931 <h5>Try to change filter criteria</h5> 932 </EmptyState> 933 )} 934 </div> 935 )} 936 </div> 937 </div> 938 <SlidingPanel isShown={this.state.groupedResources.length > 0} onClose={() => this.closeGroupedNodesPanel()}> 939 <div className='application-details__sliding-panel-pagination-wrap'> 940 <Paginate 941 page={this.state.slidingPanelPage} 942 data={this.state.groupedResources} 943 onPageChange={page => this.setState({slidingPanelPage: page})} 944 preferencesKey='grouped-nodes-details'> 945 {data => ( 946 <ApplicationResourceList 947 pref={pref} 948 onNodeClick={fullName => this.selectNode(fullName)} 949 resources={data} 950 nodeMenu={node => 951 AppUtils.renderResourceMenu(node, application, tree, this.appContext.apis, this.appChanged, () => 952 this.getApplicationActionMenu(application, false) 953 ) 954 } 955 tree={tree} 956 /> 957 )} 958 </Paginate> 959 </div> 960 </SlidingPanel> 961 <SlidingPanel isShown={selectedNode != null || isAppSelected} onClose={() => this.selectNode('')}> 962 <ResourceDetails 963 tree={tree} 964 application={application} 965 isAppSelected={isAppSelected} 966 updateApp={(app: models.Application, query: {validate?: boolean}) => this.updateApp(app, query)} 967 selectedNode={selectedNode} 968 appCxt={this.context} 969 tab={tab} 970 /> 971 </SlidingPanel> 972 <ApplicationSyncPanel 973 application={application} 974 hide={() => AppUtils.showDeploy(null, null, this.appContext.apis)} 975 selectedResource={syncResourceKey} 976 /> 977 <SlidingPanel isShown={this.selectedRollbackDeploymentIndex > -1} onClose={() => this.setRollbackPanelVisible(-1)}> 978 {this.selectedRollbackDeploymentIndex > -1 && ( 979 <ApplicationDeploymentHistory 980 app={application} 981 rollbackApp={info => this.rollbackApplication(info, application)} 982 selectDeployment={i => this.setRollbackPanelVisible(i)} 983 /> 984 )} 985 </SlidingPanel> 986 <SlidingPanel isShown={this.showOperationState && !!operationState} onClose={() => this.setOperationStatusVisible(false)}> 987 {operationState && <ApplicationOperationState application={application} operationState={operationState} />} 988 </SlidingPanel> 989 <SlidingPanel 990 isShown={this.showHydrateOperationState && !!hydrateOperationState} 991 onClose={() => this.setHydrateOperationStatusVisible(false)}> 992 {hydrateOperationState && <ApplicationHydrateOperationState hydrateOperationState={hydrateOperationState} />} 993 </SlidingPanel> 994 <SlidingPanel isShown={this.showConditions && !!conditions} onClose={() => this.setConditionsStatusVisible(false)}> 995 {conditions && <ApplicationConditions conditions={conditions} />} 996 </SlidingPanel> 997 <SlidingPanel 998 isShown={this.state.revision === 'SYNC_STATUS_REVISION' || this.state.revision === 'OPERATION_STATE_REVISION'} 999 isMiddle={true} 1000 onClose={() => this.setState({revision: null})}> 1001 {this.state.revision === 'SYNC_STATUS_REVISION' && 1002 (application.status.sync.revisions || application.status.sync.revision) && 1003 this.getContent(application, source, application.status.sync.revisions, application.status.sync.revision)} 1004 {this.state.revision === 'OPERATION_STATE_REVISION' && 1005 (application.status.operationState.syncResult.revisions || application.status.operationState.syncResult.revision) && 1006 this.getContent( 1007 application, 1008 source, 1009 application.status.operationState.syncResult.revisions, 1010 application.status.operationState.syncResult.revision 1011 )} 1012 </SlidingPanel> 1013 <SlidingPanel 1014 isShown={this.selectedExtension !== '' && activeStatusExt != null && activeStatusExt.flyout != null} 1015 onClose={() => this.setExtensionPanelVisible('')}> 1016 {this.selectedExtension !== '' && activeStatusExt?.flyout && <activeStatusExt.flyout application={application} tree={tree} />} 1017 </SlidingPanel> 1018 <SlidingPanel 1019 isMiddle={activeTopBarActionMenuExt?.isMiddle ?? true} 1020 isShown={this.selectedExtension !== '' && activeTopBarActionMenuExt != null && activeTopBarActionMenuExt.flyout != null} 1021 onClose={() => this.setExtensionPanelVisible('')}> 1022 {this.selectedExtension !== '' && activeTopBarActionMenuExt?.flyout && ( 1023 <activeTopBarActionMenuExt.flyout application={application} tree={tree} /> 1024 )} 1025 </SlidingPanel> 1026 </Page> 1027 </div> 1028 ); 1029 }} 1030 </DataLoader> 1031 )} 1032 </ObservableQuery> 1033 ); 1034 } 1035 private renderActionMenuItem(ext: TopBarActionMenuExt, tree: appModels.ApplicationTree, application: appModels.Application, showExtension?: (id: string) => any): any { 1036 return { 1037 action: () => this.setExtensionPanelVisible(ext.id), 1038 title: <ext.component application={application} tree={tree} openFlyout={() => showExtension && showExtension(ext.id)} />, 1039 iconClassName: ext.iconClassName 1040 }; 1041 } 1042 private getApplicationActionMenu(app: appModels.Application, needOverlapLabelOnNarrowScreen: boolean) { 1043 const refreshing = app.metadata.annotations && app.metadata.annotations[appModels.AnnotationRefreshKey]; 1044 const fullName = AppUtils.nodeKey({group: 'argoproj.io', kind: app.kind, name: app.metadata.name, namespace: app.metadata.namespace}); 1045 const ActionMenuItem = (prop: {actionLabel: string}) => <span className={needOverlapLabelOnNarrowScreen ? 'show-for-large' : ''}>{prop.actionLabel}</span>; 1046 return [ 1047 { 1048 iconClassName: 'fa fa-info-circle', 1049 title: <ActionMenuItem actionLabel='Details' />, 1050 action: () => this.selectNode(fullName), 1051 disabled: !app.spec.source && (!app.spec.sources || app.spec.sources.length === 0) && !app.spec.sourceHydrator 1052 }, 1053 { 1054 iconClassName: 'fa fa-file-medical', 1055 title: <ActionMenuItem actionLabel='Diff' />, 1056 action: () => this.selectNode(fullName, 0, 'diff'), 1057 disabled: 1058 app.status.sync.status === appModels.SyncStatuses.Synced || 1059 (!app.spec.source && (!app.spec.sources || app.spec.sources.length === 0) && !app.spec.sourceHydrator) 1060 }, 1061 { 1062 iconClassName: 'fa fa-sync', 1063 title: <ActionMenuItem actionLabel='Sync' />, 1064 action: () => AppUtils.showDeploy('all', null, this.appContext.apis), 1065 disabled: !app.spec.source && (!app.spec.sources || app.spec.sources.length === 0) && !app.spec.sourceHydrator 1066 }, 1067 ...(app.status?.operationState?.phase === 'Running' && app.status.resources.find(r => r.requiresDeletionConfirmation) 1068 ? [ 1069 { 1070 iconClassName: 'fa fa-check', 1071 title: <ActionMenuItem actionLabel='Confirm Pruning' />, 1072 action: () => this.confirmDeletion(app, 'Confirm Prunning', 'Are you sure you want to confirm resources pruning?') 1073 } 1074 ] 1075 : []), 1076 { 1077 iconClassName: 'fa fa-info-circle', 1078 title: <ActionMenuItem actionLabel='Sync Status' />, 1079 action: () => this.setOperationStatusVisible(true), 1080 disabled: !app.status.operationState 1081 }, 1082 { 1083 iconClassName: 'fa fa-history', 1084 title: <ActionMenuItem actionLabel='History and rollback' />, 1085 action: () => { 1086 this.setRollbackPanelVisible(0); 1087 }, 1088 disabled: !app.status.operationState 1089 }, 1090 app.metadata.deletionTimestamp && 1091 app.status.resources.find(r => r.requiresDeletionConfirmation) && 1092 !((app.metadata.annotations || {})[appModels.AppDeletionConfirmedAnnotation] == 'true') 1093 ? { 1094 iconClassName: 'fa fa-check', 1095 title: <ActionMenuItem actionLabel='Confirm Deletion' />, 1096 action: () => this.confirmDeletion(app, 'Confirm Deletion', 'Are you sure you want to delete this application?') 1097 } 1098 : { 1099 iconClassName: 'fa fa-times-circle', 1100 title: <ActionMenuItem actionLabel='Delete' />, 1101 action: () => this.deleteApplication(), 1102 disabled: !!app.metadata.deletionTimestamp 1103 }, 1104 { 1105 iconClassName: classNames('fa fa-redo', {'status-icon--spin': !!refreshing}), 1106 title: ( 1107 <React.Fragment> 1108 <ActionMenuItem actionLabel='Refresh' />{' '} 1109 <DropDownMenu 1110 items={[ 1111 { 1112 title: 'Hard Refresh', 1113 action: () => !refreshing && services.applications.get(app.metadata.name, app.metadata.namespace, 'hard') 1114 } 1115 ]} 1116 anchor={() => <i className='fa fa-caret-down' />} 1117 /> 1118 </React.Fragment> 1119 ), 1120 disabled: !!refreshing, 1121 action: () => { 1122 if (!refreshing) { 1123 services.applications.get(app.metadata.name, app.metadata.namespace, 'normal'); 1124 AppUtils.setAppRefreshing(app); 1125 this.appChanged.next(app); 1126 } 1127 } 1128 } 1129 ]; 1130 } 1131 1132 private filterTreeNode(node: ResourceTreeNode, filterInput: FilterInput): boolean { 1133 const syncStatuses = filterInput.sync.map(item => (item === 'OutOfSync' ? ['OutOfSync', 'Unknown'] : [item])).reduce((first, second) => first.concat(second), []); 1134 1135 const root = node.root || ({} as ResourceTreeNode); 1136 const hook = root && root.hook; 1137 if ( 1138 (filterInput.name.length === 0 || this.nodeNameMatchesWildcardFilters(node.name, filterInput.name)) && 1139 (filterInput.kind.length === 0 || filterInput.kind.indexOf(node.kind) > -1) && 1140 // include if node's root sync matches filter 1141 (syncStatuses.length === 0 || hook || (root.status && syncStatuses.indexOf(root.status) > -1)) && 1142 // include if node or node's root health matches filter 1143 (filterInput.health.length === 0 || 1144 hook || 1145 (root.health && filterInput.health.indexOf(root.health.status) > -1) || 1146 (node.health && filterInput.health.indexOf(node.health.status) > -1)) && 1147 (filterInput.namespace.length === 0 || filterInput.namespace.includes(node.namespace)) 1148 ) { 1149 return true; 1150 } 1151 1152 return false; 1153 } 1154 1155 private nodeNameMatchesWildcardFilters(nodeName: string, filterInputNames: string[]): boolean { 1156 const regularExpression = new RegExp( 1157 filterInputNames 1158 // Escape any regex input to ensure only * can be used 1159 .map(pattern => '^' + this.escapeRegex(pattern) + '$') 1160 // Replace any escaped * with proper regex 1161 .map(pattern => pattern.replace(/\\\*/g, '.*')) 1162 // Join all filterInputs to a single regular expression 1163 .join('|'), 1164 'gi' 1165 ); 1166 return regularExpression.test(nodeName); 1167 } 1168 1169 private escapeRegex(input: string): string { 1170 return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 1171 } 1172 1173 private loadAppInfo(name: string, appNamespace: string): Observable<{application: appModels.Application; tree: appModels.ApplicationTree}> { 1174 return from(services.applications.get(name, appNamespace)) 1175 .pipe( 1176 mergeMap(app => { 1177 const fallbackTree = { 1178 nodes: app.status.resources.map(res => ({...res, parentRefs: [], info: [], resourceVersion: '', uid: ''})), 1179 orphanedNodes: [], 1180 hosts: [] 1181 } as appModels.ApplicationTree; 1182 return combineLatest( 1183 merge( 1184 from([app]), 1185 this.appChanged.pipe(filter(item => !!item)), 1186 AppUtils.handlePageVisibility(() => 1187 services.applications 1188 .watch({name, appNamespace}) 1189 .pipe( 1190 map(watchEvent => { 1191 if (watchEvent.type === 'DELETED') { 1192 this.onAppDeleted(); 1193 } 1194 return watchEvent.application; 1195 }) 1196 ) 1197 .pipe(repeat()) 1198 .pipe(retryWhen(errors => errors.pipe(delay(500)))) 1199 ) 1200 ), 1201 merge( 1202 from([fallbackTree]), 1203 services.applications.resourceTree(name, appNamespace).catch(() => fallbackTree), 1204 AppUtils.handlePageVisibility(() => 1205 services.applications 1206 .watchResourceTree(name, appNamespace) 1207 .pipe(repeat()) 1208 .pipe(retryWhen(errors => errors.pipe(delay(500)))) 1209 ) 1210 ) 1211 ); 1212 }) 1213 ) 1214 .pipe(filter(([application, tree]) => !!application && !!tree)) 1215 .pipe(map(([application, tree]) => ({application, tree}))); 1216 } 1217 1218 private onAppDeleted() { 1219 this.appContext.apis.notifications.show({type: NotificationType.Success, content: `Application '${this.props.match.params.name}' was deleted`}); 1220 this.appContext.apis.navigation.goto('/applications'); 1221 } 1222 1223 private async updateApp(app: appModels.Application, query: {validate?: boolean}) { 1224 const latestApp = await services.applications.get(app.metadata.name, app.metadata.namespace); 1225 latestApp.metadata.labels = app.metadata.labels; 1226 latestApp.metadata.annotations = app.metadata.annotations; 1227 latestApp.spec = app.spec; 1228 const updatedApp = await services.applications.update(latestApp, query); 1229 this.appChanged.next(updatedApp); 1230 } 1231 1232 private groupAppNodesByKey(application: appModels.Application, tree: appModels.ApplicationTree) { 1233 const nodeByKey = new Map<string, appModels.ResourceDiff | appModels.ResourceNode | appModels.Application>(); 1234 tree.nodes.concat(tree.orphanedNodes || []).forEach(node => nodeByKey.set(AppUtils.nodeKey(node), node)); 1235 nodeByKey.set(AppUtils.nodeKey({group: 'argoproj.io', kind: application.kind, name: application.metadata.name, namespace: application.metadata.namespace}), application); 1236 return nodeByKey; 1237 } 1238 1239 private getTreeFilter(filterInput: string[]): FilterInput { 1240 const name = new Array<string>(); 1241 const kind = new Array<string>(); 1242 const health = new Array<string>(); 1243 const sync = new Array<string>(); 1244 const namespace = new Array<string>(); 1245 for (const item of filterInput || []) { 1246 const [type, val] = item.split(':'); 1247 switch (type) { 1248 case 'name': 1249 name.push(val); 1250 break; 1251 case 'kind': 1252 kind.push(val); 1253 break; 1254 case 'health': 1255 health.push(val); 1256 break; 1257 case 'sync': 1258 sync.push(val); 1259 break; 1260 case 'namespace': 1261 namespace.push(val); 1262 break; 1263 } 1264 } 1265 return {kind, health, sync, namespace, name}; 1266 } 1267 1268 private setOperationStatusVisible(isVisible: boolean) { 1269 this.appContext.apis.navigation.goto('.', {operation: isVisible}, {replace: true}); 1270 } 1271 1272 private setHydrateOperationStatusVisible(isVisible: boolean) { 1273 this.appContext.apis.navigation.goto('.', {hydrateOperation: isVisible}, {replace: true}); 1274 } 1275 1276 private setConditionsStatusVisible(isVisible: boolean) { 1277 this.appContext.apis.navigation.goto('.', {conditions: isVisible}, {replace: true}); 1278 } 1279 1280 private setRollbackPanelVisible(selectedDeploymentIndex = 0) { 1281 this.appContext.apis.navigation.goto('.', {rollback: selectedDeploymentIndex}, {replace: true}); 1282 } 1283 1284 private setExtensionPanelVisible(selectedExtension = '') { 1285 this.appContext.apis.navigation.goto('.', {extension: selectedExtension}, {replace: true}); 1286 } 1287 1288 private selectNode(fullName: string, containerIndex = 0, tab: string = null) { 1289 SelectNode(fullName, containerIndex, tab, this.appContext.apis); 1290 } 1291 1292 private async rollbackApplication(revisionHistory: appModels.RevisionHistory, application: appModels.Application) { 1293 try { 1294 const needDisableRollback = application.spec.syncPolicy && application.spec.syncPolicy.automated; 1295 let confirmationMessage = `Are you sure you want to rollback application '${this.props.match.params.name}'?`; 1296 if (needDisableRollback) { 1297 confirmationMessage = `Auto-Sync needs to be disabled in order for rollback to occur. 1298 Are you sure you want to disable auto-sync and rollback application '${this.props.match.params.name}'?`; 1299 } 1300 1301 await this.appContext.apis.popup.prompt( 1302 'Rollback application', 1303 api => ( 1304 <div> 1305 <p>{confirmationMessage}</p> 1306 <div className='argo-form-row'> 1307 <CheckboxField id='rollback-prune' field='prune' formApi={api} /> 1308 <label htmlFor='rollback-prune'>PRUNE</label> 1309 </div> 1310 </div> 1311 ), 1312 { 1313 submit: async (vals, _, close) => { 1314 try { 1315 if (needDisableRollback) { 1316 const update = JSON.parse(JSON.stringify(application)) as appModels.Application; 1317 update.spec.syncPolicy.automated = null; 1318 await services.applications.update(update, {validate: false}); 1319 } 1320 await services.applications.rollback(this.props.match.params.name, this.getAppNamespace(), revisionHistory.id, vals.prune); 1321 this.appChanged.next(await services.applications.get(this.props.match.params.name, this.getAppNamespace())); 1322 this.setRollbackPanelVisible(-1); 1323 close(); 1324 } catch (e) { 1325 this.appContext.apis.notifications.show({ 1326 content: <ErrorNotification title='Unable to rollback application' e={e} />, 1327 type: NotificationType.Error 1328 }); 1329 } 1330 } 1331 } 1332 ); 1333 } catch (e) { 1334 this.appContext.apis.notifications.show({ 1335 content: <ErrorNotification title='Unable to rollback application' e={e} />, 1336 type: NotificationType.Error 1337 }); 1338 } 1339 } 1340 1341 private get appContext(): AppContext { 1342 return this.context as AppContext; 1343 } 1344 1345 private async deleteApplication() { 1346 await AppUtils.deleteApplication(this.props.match.params.name, this.getAppNamespace(), this.appContext.apis); 1347 } 1348 1349 private async confirmDeletion(app: appModels.Application, title: string, message: string) { 1350 const confirmed = await this.appContext.apis.popup.confirm(title, message); 1351 if (confirmed) { 1352 if (!app.metadata.annotations) { 1353 app.metadata.annotations = {}; 1354 } 1355 app.metadata.annotations[appModels.AppDeletionConfirmedAnnotation] = new Date().toISOString(); 1356 await services.applications.update(app); 1357 } 1358 } 1359 } 1360 1361 const ExtensionView = (props: {extension: AppViewExtension; application: models.Application; tree: models.ApplicationTree}) => { 1362 const {extension, application, tree} = props; 1363 return <extension.component application={application} tree={tree} />; 1364 };