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