feat(ui): add appset tree-view details page (#26262)

Signed-off-by: Peter Jiang <peterjiang823@gmail.com>
Signed-off-by: Peter Jiang <35584807+pjiang-dev@users.noreply.github.com>
Co-authored-by: reggie-k <regina.voloshin@codefresh.io>
Co-authored-by: Keith Chong <kykchong@redhat.com>
This commit is contained in:
Peter Jiang
2026-02-11 12:52:32 -08:00
committed by GitHub
parent 21acbb861d
commit 1391e2f95f
7 changed files with 470 additions and 188 deletions

View File

@@ -2,14 +2,24 @@ import * as React from 'react';
import {Timestamp} from '../../../shared/components';
import * as models from '../../../shared/models';
import {getConditionCategory} from '../utils';
import {getAppSetConditionCategory, getConditionCategory} from '../utils';
import './application-conditions.scss';
export const ApplicationConditions = ({conditions}: {conditions: models.ApplicationCondition[]}) => {
interface ApplicationConditionsProps {
conditions: models.ApplicationCondition[];
title?: string;
}
interface ApplicationSetConditionsProps {
conditions: models.ApplicationSetCondition[];
title?: string;
}
export const ApplicationConditions = ({conditions, title = 'Application conditions'}: ApplicationConditionsProps) => {
return (
<div className='application-conditions'>
<h4>Application conditions</h4>
<h4>{title}</h4>
{(conditions.length === 0 && <p>Application is healthy</p>) || (
<div className='argo-table-list'>
{conditions.map((condition, index) => (
@@ -30,3 +40,33 @@ export const ApplicationConditions = ({conditions}: {conditions: models.Applicat
</div>
);
};
export const ApplicationSetConditions = ({conditions, title = 'ApplicationSet conditions'}: ApplicationSetConditionsProps) => {
return (
<div className='application-conditions'>
<h4>{title}</h4>
{(conditions.length === 0 && <p>ApplicationSet is healthy</p>) || (
<div className='argo-table-list'>
{conditions.map((condition, index) => (
<div
className={`argo-table-list__row application-conditions__condition application-conditions__condition--${getAppSetConditionCategory(condition)}`}
key={index}>
<div className='row'>
<div className='columns small-2'>
{condition.type}
{condition.status && <span className='application-conditions__status'> ({condition.status})</span>}
</div>
<div className='columns small-7' style={{whiteSpace: 'normal', lineHeight: 'normal'}}>
{condition.message}
</div>
<div className='columns small-3'>
<Timestamp date={condition.lastTransitionTime} />
</div>
</div>
</div>
))}
</div>
)}
</div>
);
};

View File

@@ -12,13 +12,15 @@ import {AppContext, Context, ContextApis} from '../../../shared/context';
import * as appModels from '../../../shared/models';
import {AppDetailsPreferences, AppsDetailsViewKey, AppsDetailsViewType, services} from '../../../shared/services';
import {ApplicationConditions} from '../application-conditions/application-conditions';
import {ApplicationConditions, ApplicationSetConditions} from '../application-conditions/application-conditions';
import {ApplicationDeploymentHistory} from '../application-deployment-history/application-deployment-history';
import {ApplicationOperationState} from '../application-operation-state/application-operation-state';
import {PodGroupType, PodView} from '../application-pod-view/pod-view';
import {ApplicationResourceTree, ResourceTreeNode} from '../application-resource-tree/application-resource-tree';
import {ApplicationStatusPanel} from '../application-status-panel/application-status-panel';
import {ApplicationSetStatusPanel} from '../application-status-panel/appset-status-panel';
import {ApplicationSyncPanel} from '../application-sync-panel/application-sync-panel';
import {isApp} from '../utils';
import {ResourceDetails} from '../resource-details/resource-details';
import * as AppUtils from '../utils';
import {ApplicationResourceList} from './application-resource-list';
@@ -647,8 +649,9 @@ Are you sure you want to disable auto-sync and rollback application '${props.mat
})
)
}>
{({application, tree, pref}: {application: appModels.Application; tree: appModels.ApplicationTree; pref: AppDetailsPreferences}) => {
{({application, tree, pref}: {application: appModels.AbstractApplication; tree: appModels.ApplicationTree; pref: AppDetailsPreferences}) => {
tree.nodes = tree.nodes || [];
const isApplication = isApp(application);
const treeFilter = getTreeFilter(pref.resourceFilter);
const setFilter = (items: string[]) => {
appContext.navigation.goto('.', {resource: items.join(',')}, {replace: true});
@@ -656,20 +659,25 @@ Are you sure you want to disable auto-sync and rollback application '${props.mat
};
const clearFilter = () => setFilter([]);
const refreshing = application.metadata.annotations && application.metadata.annotations[appModels.AnnotationRefreshKey];
const appNodesByName = groupAppNodesByKey(application, tree);
const appNodesByName = isApplication ? groupAppNodesByKey(application as appModels.Application, tree) : new Map();
const selectedItem = (selectedNodeKey && appNodesByName.get(selectedNodeKey)) || null;
const isAppSelected = selectedItem === application;
const selectedNode = !isAppSelected && (selectedItem as appModels.ResourceNode);
const operationState = application.status.operationState;
const hydrateOperationState = application.status.sourceHydrator.currentOperation;
const conditions = application.status.conditions || [];
const operationState = isApplication ? (application as appModels.Application).status.operationState : undefined;
const hydrateOperationState = isApplication ? (application as appModels.Application).status.sourceHydrator?.currentOperation : undefined;
const conditions = application.status?.conditions || [];
const syncResourceKey = new URLSearchParams(props.history.location.search).get('deploy');
const tab = new URLSearchParams(props.history.location.search).get('tab');
const source = getAppDefaultSource(application);
const source = isApplication ? getAppDefaultSource(application as appModels.Application) : undefined;
const showToolTip = pref?.userHelpTipMsgs.find(usrMsg => usrMsg.appName === application.metadata.name);
const resourceNodes = (): any[] => {
if (!isApplication) {
// For ApplicationSets, use tree nodes directly
return tree.nodes.map(node => ({...node, orphaned: false}));
}
const app = application as appModels.Application;
const statusByKey = new Map<string, models.ResourceStatus>();
application.status.resources.forEach(res => statusByKey.set(AppUtils.nodeKey(res), res));
app.status.resources.forEach(res => statusByKey.set(AppUtils.nodeKey(res), res));
const resources = new Map<string, any>();
tree.nodes
.map(node => ({...node, orphaned: false}))
@@ -705,6 +713,63 @@ Are you sure you want to disable auto-sync and rollback application '${props.mat
: []
}));
};
// Helper to get ApplicationResourceTree props based on resource type
const getResourceTreeProps = () => {
const commonProps = {
nodeFilter: (node: ResourceTreeNode) => filterTreeNode(node, treeFilter),
selectedNodeFullName: selectedNodeKey,
showCompactNodes: pref.groupNodes,
userMsgs: pref.userHelpTipMsgs,
tree,
onClearFilter: clearFilter,
onGroupdNodeClick: (nodeIds: string[]) => openGroupNodeDetails(nodeIds),
zoom: pref.zoom,
appContext: {...appContext, apis: appContext} as unknown as AppContext,
nameDirection: state.truncateNameOnRight,
nameWrap: state.showFullNodeName,
filters: pref.resourceFilter,
setTreeFilterGraph: setFilterGraph,
updateUsrHelpTipMsgs: updateHelpTipState,
setShowCompactNodes,
setNodeExpansion: (node: string, isExpanded: boolean) => setNodeExpansion(node, isExpanded),
getNodeExpansion: (node: string) => getNodeExpansion(node)
};
if (isApplication) {
return {
...commonProps,
onNodeClick: (fullName: string) => selectNode(fullName),
nodeMenu: (node: ResourceTreeNode) =>
AppUtils.renderResourceMenu(node, application as appModels.Application, tree, appContext, appChanged.current, () =>
getApplicationActionMenu(application as appModels.Application, false)
),
app: application as appModels.Application,
showOrphanedResources: pref.orphanedResources,
useNetworkingHierarchy: pref.view === 'network',
podGroupCount: pref.podGroupCount
};
} else {
return {
...commonProps,
onNodeClick: (fullName: string) => {
// For ApplicationSets, navigate to Application details if clicking an Application node
const parts = fullName.split('/');
const [group, kind, namespace, name] = parts;
if (group === 'argoproj.io' && kind === 'Application' && namespace && name) {
appContext.navigation.goto(`/applications/${namespace}/${name}`);
} else {
selectNode(fullName);
}
},
app: application,
showOrphanedResources: false,
useNetworkingHierarchy: false,
podGroupCount: 0
};
}
};
const {Tree, Pods, Network, List} = AppsDetailsViewKey;
const zoomNum = (pref.zoom * 100).toFixed(0);
const setZoom = (s: number) => {
@@ -759,7 +824,7 @@ Are you sure you want to disable auto-sync and rollback application '${props.mat
});
setState(prevState => ({...prevState, collapsedNodes: collapsedNodesList}));
} else {
const managedKeys = new Set(application.status.resources.map(AppUtils.nodeKey));
const managedKeys = isApplication ? new Set((application as appModels.Application).status.resources.map(AppUtils.nodeKey)) : new Set<string>();
nodes.forEach(node => {
if (!((node.parentRefs || []).length === 0 || managedKeys.has(AppUtils.nodeKey(node)))) {
node.parentRefs.forEach(parent => {
@@ -784,9 +849,9 @@ Are you sure you want to disable auto-sync and rollback application '${props.mat
const activeStatusExt = state.statusExtensionsMap[selectedExtension];
const activeTopBarActionMenuExt = state.topBarActionMenuExtsMap[selectedExtension];
if (state.extensionsMap[pref.view] != null) {
if (isApplication && state.extensionsMap[pref.view] != null) {
const extension = state.extensionsMap[pref.view];
if (!extension.shouldDisplay(application)) {
if (!extension.shouldDisplay(application as appModels.Application)) {
appContext.navigation.goto('.', {view: Tree});
}
}
@@ -799,16 +864,27 @@ Are you sure you want to disable auto-sync and rollback application '${props.mat
topBarTitle={getPageTitle(pref.view)}
toolbar={{
breadcrumbs: [
{title: 'Applications', path: '/applications'},
{
title: isApplication ? 'Applications' : 'ApplicationSets',
path: isApplication ? '/applications' : '/applicationsets'
},
{title: <ApplicationsDetailsAppDropdown appName={props.match.params.name} objectListKind={objectListKind} />}
],
actionMenu: {
items: [
...getApplicationActionMenu(application, true),
...(state.topBarActionMenuExts
?.filter(ext => ext.shouldDisplay?.(application))
.map(ext => renderActionMenuItem(ext, tree, application, setExtensionPanelVisible)) || [])
]
items: isApplication
? [
...getApplicationActionMenu(application as appModels.Application, true),
...(state.topBarActionMenuExts
?.filter(ext => ext.shouldDisplay?.(application as appModels.Application))
.map(ext => renderActionMenuItem(ext, tree, application as appModels.Application, setExtensionPanelVisible)) || [])
]
: [
{
title: 'AppSet Details',
iconClassName: 'fa fa-info-circle',
action: () => selectNode(appFullName)
}
]
},
tools: (
<React.Fragment key='app-list-tools'>
@@ -821,22 +897,26 @@ Are you sure you want to disable auto-sync and rollback application '${props.mat
services.viewPreferences.updatePreferences({appDetails: {...pref, view: Tree}});
}}
/>
<i
className={classNames('fa fa-th', {selected: pref.view === Pods})}
title='Pods'
onClick={() => {
appContext.navigation.goto('.', {view: Pods});
services.viewPreferences.updatePreferences({appDetails: {...pref, view: Pods}});
}}
/>
<i
className={classNames('fa fa-network-wired', {selected: pref.view === Network})}
title='Network'
onClick={() => {
appContext.navigation.goto('.', {view: Network});
services.viewPreferences.updatePreferences({appDetails: {...pref, view: Network}});
}}
/>
{isApplication && (
<>
<i
className={classNames('fa fa-th', {selected: pref.view === Pods})}
title='Pods'
onClick={() => {
appContext.navigation.goto('.', {view: Pods});
services.viewPreferences.updatePreferences({appDetails: {...pref, view: Pods}});
}}
/>
<i
className={classNames('fa fa-network-wired', {selected: pref.view === Network})}
title='Network'
onClick={() => {
appContext.navigation.goto('.', {view: Network});
services.viewPreferences.updatePreferences({appDetails: {...pref, view: Network}});
}}
/>
</>
)}
<i
className={classNames('fa fa-th-list', {selected: pref.view === List})}
title='List'
@@ -845,9 +925,10 @@ Are you sure you want to disable auto-sync and rollback application '${props.mat
services.viewPreferences.updatePreferences({appDetails: {...pref, view: List}});
}}
/>
{state.extensions &&
{isApplication &&
state.extensions &&
(state.extensions || [])
.filter(ext => ext.shouldDisplay(application))
.filter(ext => ext.shouldDisplay(application as appModels.Application))
.map(ext => (
<i
key={ext.title}
@@ -865,15 +946,22 @@ Are you sure you want to disable auto-sync and rollback application '${props.mat
}}>
<div className='application-details__wrapper'>
<div className='application-details__status-panel'>
<ApplicationStatusPanel
application={application}
showDiff={() => selectNode(appFullName, 0, 'diff')}
showOperation={() => setOperationStatusVisible(true)}
showHydrateOperation={() => setHydrateOperationStatusVisible(true)}
showConditions={() => setConditionsStatusVisible(true)}
showExtension={id => setExtensionPanelVisible(id)}
showMetadataInfo={revision => setState(prevState => ({...prevState, revision}))}
/>
{isApplication ? (
<ApplicationStatusPanel
application={application as appModels.Application}
showDiff={() => selectNode(appFullName, 0, 'diff')}
showOperation={() => setOperationStatusVisible(true)}
showHydrateOperation={() => setHydrateOperationStatusVisible(true)}
showConditions={() => setConditionsStatusVisible(true)}
showExtension={id => setExtensionPanelVisible(id)}
showMetadataInfo={revision => setState(prevState => ({...prevState, revision}))}
/>
) : (
<ApplicationSetStatusPanel
appSet={application as appModels.ApplicationSet}
showConditions={() => setConditionsStatusVisible(true)}
/>
)}
</div>
<div className='application-details__tree'>
{refreshing && <p className='application-details__refreshing-label'>Refreshing</p>}
@@ -950,52 +1038,26 @@ Are you sure you want to disable auto-sync and rollback application '${props.mat
<div className={`zoom-value`}>{zoomNum}%</div>
</span>
</div>
<ApplicationResourceTree
nodeFilter={node => filterTreeNode(node, treeFilter)}
selectedNodeFullName={selectedNodeKey}
onNodeClick={fullName => selectNode(fullName)}
nodeMenu={node =>
AppUtils.renderResourceMenu(node, application, tree, appContext, appChanged.current, () =>
getApplicationActionMenu(application, false)
)
}
showCompactNodes={pref.groupNodes}
userMsgs={pref.userHelpTipMsgs}
tree={tree}
app={application}
showOrphanedResources={pref.orphanedResources}
useNetworkingHierarchy={pref.view === 'network'}
onClearFilter={clearFilter}
onGroupdNodeClick={groupdedNodeIds => openGroupNodeDetails(groupdedNodeIds)}
zoom={pref.zoom}
podGroupCount={pref.podGroupCount}
appContext={{...appContext, apis: appContext} as unknown as AppContext}
nameDirection={state.truncateNameOnRight}
nameWrap={state.showFullNodeName}
filters={pref.resourceFilter}
setTreeFilterGraph={setFilterGraph}
updateUsrHelpTipMsgs={updateHelpTipState}
setShowCompactNodes={setShowCompactNodes}
setNodeExpansion={(node, isExpanded) => setNodeExpansion(node, isExpanded)}
getNodeExpansion={node => getNodeExpansion(node)}
/>
<ApplicationResourceTree {...getResourceTreeProps()} />
</>
)) ||
(pref.view === 'pods' && (
(isApplication && pref.view === 'pods' && (
<PodView
tree={tree}
app={application}
app={application as appModels.Application}
onItemClick={fullName => selectNode(fullName)}
nodeMenu={node =>
AppUtils.renderResourceMenu(node, application, tree, appContext, appChanged.current, () =>
getApplicationActionMenu(application, false)
AppUtils.renderResourceMenu(node, application as appModels.Application, tree, appContext, appChanged.current, () =>
getApplicationActionMenu(application as appModels.Application, false)
)
}
quickStarts={node => AppUtils.renderResourceButtons(node, application, tree, appContext, appChanged.current)}
quickStarts={node =>
AppUtils.renderResourceButtons(node, application as appModels.Application, tree, appContext, appChanged.current)
}
/>
)) ||
(state.extensionsMap[pref.view] != null && (
<ExtensionView extension={state.extensionsMap[pref.view]} application={application} tree={tree} />
(isApplication && state.extensionsMap[pref.view] != null && (
<ExtensionView extension={state.extensionsMap[pref.view]} application={application as appModels.Application} tree={tree} />
)) || (
<div>
<DataLoader load={() => services.viewPreferences.getPreferences()}>
@@ -1021,10 +1083,18 @@ Are you sure you want to disable auto-sync and rollback application '${props.mat
pref={pref}
onNodeClick={fullName => selectNode(fullName)}
resources={data}
nodeMenu={node =>
AppUtils.renderResourceMenu(node, application, tree, appContext, appChanged.current, () =>
getApplicationActionMenu(application, false)
)
nodeMenu={
isApplication
? node =>
AppUtils.renderResourceMenu(
node,
application as appModels.Application,
tree,
appContext,
appChanged.current,
() => getApplicationActionMenu(application as appModels.Application, false)
)
: undefined
}
tree={tree}
/>
@@ -1040,92 +1110,128 @@ Are you sure you want to disable auto-sync and rollback application '${props.mat
)}
</div>
</div>
<SlidingPanel isShown={state.groupedResources.length > 0} onClose={() => closeGroupedNodesPanel()}>
<div className='application-details__sliding-panel-pagination-wrap'>
<Paginate
page={state.slidingPanelPage}
data={state.groupedResources}
onPageChange={page => setState(prevState => ({...prevState, slidingPanelPage: page}))}
preferencesKey='grouped-nodes-details'>
{data => (
<ApplicationResourceList
pref={pref}
onNodeClick={fullName => selectNode(fullName)}
resources={data}
nodeMenu={node =>
AppUtils.renderResourceMenu(node, application, tree, appContext, appChanged.current, () =>
getApplicationActionMenu(application, false)
)
}
tree={tree}
/>
)}
</Paginate>
</div>
</SlidingPanel>
<SlidingPanel isShown={selectedNode != null || isAppSelected} onClose={() => selectNode('')}>
<ResourceDetails
tree={tree}
application={application}
isAppSelected={isAppSelected}
updateApp={(app: models.Application, query: {validate?: boolean}) => updateApp(app, query)}
selectedNode={selectedNode}
appCxt={{...appContext, apis: appContext} as unknown as AppContext}
tab={tab}
/>
</SlidingPanel>
<ApplicationSyncPanel
application={application}
hide={() => AppUtils.showDeploy(null, null, appContext)}
selectedResource={syncResourceKey}
/>
<SlidingPanel isShown={selectedRollbackDeploymentIndex > -1} onClose={() => setRollbackPanelVisible(-1)}>
{selectedRollbackDeploymentIndex > -1 && (
<ApplicationDeploymentHistory
app={application}
rollbackApp={info => rollbackApplication(info, application)}
selectDeployment={i => setRollbackPanelVisible(i)}
{isApplication && (
<SlidingPanel isShown={state.groupedResources.length > 0} onClose={() => closeGroupedNodesPanel()}>
<div className='application-details__sliding-panel-pagination-wrap'>
<Paginate
page={state.slidingPanelPage}
data={state.groupedResources}
onPageChange={page => setState(prevState => ({...prevState, slidingPanelPage: page}))}
preferencesKey='grouped-nodes-details'>
{data => (
<ApplicationResourceList
pref={pref}
onNodeClick={fullName => selectNode(fullName)}
resources={data}
nodeMenu={node =>
AppUtils.renderResourceMenu(
node,
application as appModels.Application,
tree,
appContext,
appChanged.current,
() => getApplicationActionMenu(application as appModels.Application, false)
)
}
tree={tree}
/>
)}
</Paginate>
</div>
</SlidingPanel>
)}
{isApplication && (
<SlidingPanel isShown={selectedNode != null || isAppSelected} onClose={() => selectNode('')}>
<ResourceDetails
tree={tree}
application={application as appModels.Application}
isAppSelected={isAppSelected}
updateApp={(app: models.Application, query: {validate?: boolean}) => updateApp(app, query)}
selectedNode={selectedNode}
appCxt={{...appContext, apis: appContext} as unknown as AppContext}
tab={tab}
/>
)}
</SlidingPanel>
<SlidingPanel isShown={showOperationState && !!operationState} onClose={() => setOperationStatusVisible(false)}>
{operationState && <ApplicationOperationState application={application} operationState={operationState} />}
</SlidingPanel>
<SlidingPanel isShown={showHydrateOperationState && !!hydrateOperationState} onClose={() => setHydrateOperationStatusVisible(false)}>
{hydrateOperationState && <ApplicationHydrateOperationState hydrateOperationState={hydrateOperationState} />}
</SlidingPanel>
<SlidingPanel isShown={showConditions && !!conditions} onClose={() => setConditionsStatusVisible(false)}>
{conditions && <ApplicationConditions conditions={conditions} />}
</SlidingPanel>
<SlidingPanel
isShown={state.revision === 'SYNC_STATUS_REVISION' || state.revision === 'OPERATION_STATE_REVISION'}
isMiddle={true}
onClose={() => setState(prevState => ({...prevState, revision: null}))}>
{state.revision === 'SYNC_STATUS_REVISION' &&
(application.status.sync.revisions || application.status.sync.revision) &&
getContent(application, source, application.status.sync.revisions, application.status.sync.revision)}
{state.revision === 'OPERATION_STATE_REVISION' &&
(application.status.operationState.syncResult.revisions || application.status.operationState.syncResult.revision) &&
getContent(
application,
source,
application.status.operationState.syncResult.revisions,
application.status.operationState.syncResult.revision
</SlidingPanel>
)}
{isApplication && (
<ApplicationSyncPanel
application={application as appModels.Application}
hide={() => AppUtils.showDeploy(null, null, appContext)}
selectedResource={syncResourceKey}
/>
)}
{isApplication && (
<SlidingPanel isShown={selectedRollbackDeploymentIndex > -1} onClose={() => setRollbackPanelVisible(-1)}>
{selectedRollbackDeploymentIndex > -1 && (
<ApplicationDeploymentHistory
app={application as appModels.Application}
rollbackApp={info => rollbackApplication(info, application as appModels.Application)}
selectDeployment={i => setRollbackPanelVisible(i)}
/>
)}
</SlidingPanel>
)}
{isApplication && (
<SlidingPanel isShown={showOperationState && !!operationState} onClose={() => setOperationStatusVisible(false)}>
{operationState && <ApplicationOperationState application={application as appModels.Application} operationState={operationState} />}
</SlidingPanel>
)}
{isApplication && (
<SlidingPanel isShown={showHydrateOperationState && !!hydrateOperationState} onClose={() => setHydrateOperationStatusVisible(false)}>
{hydrateOperationState && <ApplicationHydrateOperationState hydrateOperationState={hydrateOperationState} />}
</SlidingPanel>
)}
<SlidingPanel isShown={showConditions && !!conditions} onClose={() => setConditionsStatusVisible(false)}>
{conditions &&
(isApplication ? (
<ApplicationConditions conditions={conditions as appModels.ApplicationCondition[]} />
) : (
<ApplicationSetConditions conditions={conditions as appModels.ApplicationSetCondition[]} />
))}
</SlidingPanel>
<SlidingPanel
isShown={selectedExtension !== '' && activeStatusExt != null && activeStatusExt.flyout != null}
onClose={() => setExtensionPanelVisible('')}>
{selectedExtension !== '' && activeStatusExt?.flyout && <activeStatusExt.flyout application={application} tree={tree} />}
</SlidingPanel>
<SlidingPanel
isMiddle={activeTopBarActionMenuExt?.isMiddle ?? true}
isShown={selectedExtension !== '' && activeTopBarActionMenuExt != null && activeTopBarActionMenuExt.flyout != null}
onClose={() => setExtensionPanelVisible('')}>
{selectedExtension !== '' && activeTopBarActionMenuExt?.flyout && (
<activeTopBarActionMenuExt.flyout application={application} tree={tree} />
)}
</SlidingPanel>
{isApplication && (
<SlidingPanel
isShown={state.revision === 'SYNC_STATUS_REVISION' || state.revision === 'OPERATION_STATE_REVISION'}
isMiddle={true}
onClose={() => setState(prevState => ({...prevState, revision: null}))}>
{state.revision === 'SYNC_STATUS_REVISION' &&
((application as appModels.Application).status.sync.revisions || (application as appModels.Application).status.sync.revision) &&
getContent(
application as appModels.Application,
source,
(application as appModels.Application).status.sync.revisions,
(application as appModels.Application).status.sync.revision
)}
{state.revision === 'OPERATION_STATE_REVISION' &&
((application as appModels.Application).status.operationState.syncResult.revisions ||
(application as appModels.Application).status.operationState.syncResult.revision) &&
getContent(
application as appModels.Application,
source,
(application as appModels.Application).status.operationState.syncResult.revisions,
(application as appModels.Application).status.operationState.syncResult.revision
)}
</SlidingPanel>
)}
{isApplication && (
<SlidingPanel
isShown={selectedExtension !== '' && activeStatusExt != null && activeStatusExt.flyout != null}
onClose={() => setExtensionPanelVisible('')}>
{selectedExtension !== '' && activeStatusExt?.flyout && (
<activeStatusExt.flyout application={application as appModels.Application} tree={tree} />
)}
</SlidingPanel>
)}
{isApplication && (
<SlidingPanel
isMiddle={activeTopBarActionMenuExt?.isMiddle ?? true}
isShown={selectedExtension !== '' && activeTopBarActionMenuExt != null && activeTopBarActionMenuExt.flyout != null}
onClose={() => setExtensionPanelVisible('')}>
{selectedExtension !== '' && activeTopBarActionMenuExt?.flyout && (
<activeTopBarActionMenuExt.flyout application={application as appModels.Application} tree={tree} />
)}
</SlidingPanel>
)}
</Page>
</div>
);

View File

@@ -16,7 +16,9 @@ import {
BASE_COLORS,
ComparisonStatusIcon,
getAppOverridesCount,
getAppSetHealthStatus,
HealthStatusIcon,
isApp,
isAppNode,
isYoungerThanXMinutes,
NodeId,
@@ -50,7 +52,7 @@ export interface ResourceTreeNode extends models.ResourceNode {
}
export interface ApplicationResourceTreeProps {
app: models.Application;
app: models.AbstractApplication;
tree: models.ApplicationTree;
useNetworkingHierarchy: boolean;
nodeFilter: (node: ResourceTreeNode) => boolean;
@@ -244,7 +246,7 @@ export function compareNodes(first: ResourceTreeNode, second: ResourceTreeNode)
);
}
function appNodeKey(app: models.Application) {
function appNodeKey(app: models.AbstractApplication) {
return nodeKey({group: 'argoproj.io', kind: app.kind, name: app.metadata.name, namespace: app.metadata.namespace});
}
@@ -407,7 +409,7 @@ function renderPodGroup(props: ApplicationResourceTreeProps, id: string, node: R
}
const appNode = isAppNode(node);
const rootNode = !node.root;
const extLinks: string[] = props.app.status.summary.externalURLs;
const extLinks: string[] = isApp(props.app) ? (props.app as models.Application).status.summary.externalURLs : [];
const podGroupChildren = childMap.get(treeNodeKey(node));
const nonPodChildren = podGroupChildren?.reduce((acc, child) => {
if (child.kind !== 'Pod') {
@@ -755,7 +757,7 @@ function renderResourceNode(props: ApplicationResourceTreeProps, id: string, nod
}
const appNode = isAppNode(node);
const rootNode = !node.root;
const extLinks: string[] = props.app.status.summary.externalURLs;
const extLinks: string[] = isApp(props.app) ? (props.app as models.Application).status.summary.externalURLs : [];
const childCount = nodesHavingChildren.get(node.uid);
return (
<div
@@ -919,8 +921,8 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) =>
version: '',
// @ts-expect-error its not any
children: [],
status: props.app.status.sync.status,
health: props.app.status.health,
status: isApp(props.app) ? (props.app as models.Application).status.sync.status : null,
health: isApp(props.app) ? (props.app as models.Application).status.health : {status: getAppSetHealthStatus(props.app as models.ApplicationSet), message: ''},
uid: props.app.kind + '-' + props.app.metadata.namespace + '-' + props.app.metadata.name,
info:
overridesCount > 0
@@ -934,19 +936,34 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) =>
};
const statusByKey = new Map<string, models.ResourceStatus>();
props.app.status.resources.forEach(res => statusByKey.set(nodeKey(res), res));
const appSetStatusByKey = new Map<string, models.ApplicationSetResource>();
if (isApp(props.app)) {
(props.app as models.Application).status.resources.forEach(res => statusByKey.set(nodeKey(res), res));
} else if ((props.app as models.ApplicationSet).status?.resources) {
(props.app as models.ApplicationSet).status.resources.forEach(res => appSetStatusByKey.set(nodeKey(res), res));
}
const nodeByKey = new Map<string, ResourceTreeNode>();
props.tree.nodes
.map(node => ({...node, orphaned: false}))
.concat(((props.showOrphanedResources && props.tree.orphanedNodes) || []).map(node => ({...node, orphaned: true})))
.forEach(node => {
const status = statusByKey.get(nodeKey(node));
const resourceNode: ResourceTreeNode = {...node};
if (status) {
resourceNode.health = status.health;
resourceNode.status = status.status;
resourceNode.hook = status.hook;
resourceNode.requiresPruning = status.requiresPruning;
if (isApp(props.app)) {
const status = statusByKey.get(nodeKey(node));
if (status) {
resourceNode.health = status.health;
resourceNode.status = status.status;
resourceNode.hook = status.hook;
resourceNode.requiresPruning = status.requiresPruning;
}
} else {
const status = appSetStatusByKey.get(nodeKey(node));
if (status && status.health) {
resourceNode.health = {
status: status.health.status as models.HealthStatusCode,
message: ''
};
}
}
nodeByKey.set(treeNodeKey(node), resourceNode);
});
@@ -979,7 +996,7 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) =>
}
}, [podCount]);
function filterGraph(app: models.Application, filteredIndicatorParent: string, graphNodesFilter: dagre.graphlib.Graph, predicate: (node: ResourceTreeNode) => boolean) {
function filterGraph(app: models.AbstractApplication, filteredIndicatorParent: string, graphNodesFilter: dagre.graphlib.Graph, predicate: (node: ResourceTreeNode) => boolean) {
const appKey = appNodeKey(app);
let filtered = 0;
graphNodesFilter.nodes().forEach(nodeId => {
@@ -1118,8 +1135,12 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) =>
}
} else {
// Tree view
const managedKeys = new Set(props.app.status.resources.map(nodeKey));
const orphanedKeys = new Set(props.tree.orphanedNodes?.map(nodeKey));
const managedKeys = isApp(props.app)
? new Set((props.app as models.Application).status.resources.map(nodeKey))
: (props.app as models.ApplicationSet).status?.resources
? new Set((props.app as models.ApplicationSet).status.resources.map(nodeKey))
: new Set<string>();
const orphanedKeys = isApp(props.app) ? new Set(props.tree.orphanedNodes?.map(nodeKey)) : new Set<string>();
const orphans: ResourceTreeNode[] = [];
let allChildNodes: ResourceTreeNode[] = [];
nodesHavingChildren.set(appNode.uid, 1);

View File

@@ -0,0 +1,89 @@
import {HelpIcon} from 'argo-ui';
import * as React from 'react';
import {ARGO_GRAY6_COLOR} from '../../../shared/components';
import {Timestamp} from '../../../shared/components/timestamp';
import * as models from '../../../shared/models';
import {getAppSetConditionCategory, getAppSetHealthStatus, HealthStatusIcon} from '../utils';
import './application-status-panel.scss';
interface Props {
appSet: models.ApplicationSet;
showConditions?: () => any;
}
interface SectionInfo {
title: string;
helpContent?: string;
}
const sectionLabel = (info: SectionInfo) => (
<label style={{display: 'flex', alignItems: 'flex-start', fontSize: '12px', fontWeight: 600, color: ARGO_GRAY6_COLOR, minHeight: '18px'}}>
{info.title}
{info.helpContent && (
<span style={{marginLeft: '5px'}}>
<HelpIcon title={info.helpContent} />
</span>
)}
</label>
);
const getConditionCounts = (conditions: models.ApplicationSetCondition[]) => {
const counts = {info: 0, warning: 0, error: 0};
if (!conditions) return counts;
conditions.forEach(c => {
const category = getAppSetConditionCategory(c);
counts[category]++;
});
return counts;
};
export const ApplicationSetStatusPanel = ({appSet, showConditions}: Props) => {
const healthStatus = getAppSetHealthStatus(appSet);
const conditions = appSet.status?.conditions || [];
const conditionCounts = getConditionCounts(conditions);
const latestCondition = conditions.length > 0 ? conditions[conditions.length - 1] : null;
return (
<div className='application-status-panel row'>
<div className='application-status-panel__item'>
{sectionLabel({title: 'APPSET HEALTH', helpContent: 'The health status of your ApplicationSet derived from its conditions'})}
<div className='application-status-panel__item-value'>
<HealthStatusIcon state={{status: healthStatus, message: ''}} />
&nbsp;
{healthStatus}
</div>
{latestCondition?.message && <div className='application-status-panel__item-name'>{latestCondition.message}</div>}
</div>
{conditions.length > 0 && (
<div className='application-status-panel__item'>
{sectionLabel({title: 'CONDITIONS'})}
<div className='application-status-panel__item-value application-status-panel__conditions' onClick={() => showConditions && showConditions()}>
{conditionCounts.info > 0 && (
<a className='info'>
<i className='fa fa-info-circle application-status-panel__item-value__status-button' /> {conditionCounts.info} Info
</a>
)}
{conditionCounts.error > 0 && (
<a className='error'>
<i className='fa fa-exclamation-circle application-status-panel__item-value__status-button' /> {conditionCounts.error} Error
{conditionCounts.error !== 1 && 's'}
</a>
)}
</div>
</div>
)}
{latestCondition?.lastTransitionTime && (
<div className='application-status-panel__item'>
{sectionLabel({title: 'LAST UPDATED'})}
<div className='application-status-panel__item-value'>
<Timestamp date={latestCondition.lastTransitionTime} />
</div>
</div>
)}
</div>
);
};

View File

@@ -6,6 +6,7 @@ import * as AppUtils from '../utils';
import {getApplicationLinkURL, getManagedByURL, getAppSetHealthStatus} from '../utils';
import {services} from '../../../shared/services';
import {ViewPreferences} from '../../../shared/services';
import {ResourceIcon} from '../resource-icon';
export interface AppSetTileProps {
appSet: models.ApplicationSet;
@@ -50,7 +51,7 @@ export const AppSetTile = ({appSet, selected, pref, ctx, tileRef}: AppSetTilePro
{/* Header row with icon, title, and action buttons */}
<div className='row'>
<div className='columns small-11'>
<i className='icon argo-icon-git' />
<ResourceIcon group='argoproj.io' kind='ApplicationSet' customStyle={{marginRight: '5px'}} />
<Tooltip content={AppUtils.appInstanceName(appSet)}>
<span className='applications-list__title'>{AppUtils.appQualifiedName(appSet, useAuthSettingsCtx?.appsInAnyNamespaceEnabled)}</span>
</Tooltip>

View File

@@ -10,6 +10,15 @@ export const ResourceIcon = ({group, kind, customStyle}: {group: string; kind: s
if (kind === 'Application') {
return <i title={kind} className={`icon argo-icon-application`} style={customStyle} />;
}
if (kind === 'ApplicationSet') {
return (
<span title={kind} style={{display: 'inline-flex', alignItems: 'center', ...customStyle}}>
{'{'}
<i className='icon argo-icon-application' style={{margin: '0 1px'}} />
{'}'}
</span>
);
}
// First, check for group-based custom icons
if (group) {
const matchedGroup = matchGroupToResource(group);

View File

@@ -1425,6 +1425,22 @@ export function getConditionCategory(condition: appModels.ApplicationCondition):
}
}
export function getAppSetConditionCategory(condition: appModels.ApplicationSetCondition): 'error' | 'warning' | 'info' {
const status = condition.status?.toLowerCase();
const type = condition.type;
// ErrorOccurred with status True = error
if (type === 'ErrorOccurred' && status === 'true') {
return 'error';
}
// ParametersGenerated or ResourcesUpToDate with status False = error (indicates failure)
if ((type === 'ParametersGenerated' || type === 'ResourcesUpToDate') && status === 'false') {
return 'error';
}
// Otherwise it's informational
return 'info';
}
export function isAppNode(node: appModels.ResourceNode) {
return node.kind === 'Application' && node.group === 'argoproj.io';
}