feat: expose Appset UI and fix pie chart summary (#26666)

Signed-off-by: Peter Jiang <[email protected]>
Signed-off-by: Peter Jiang <[email protected]>
Co-authored-by: Alexy Mantha <[email protected]>
This commit is contained in:
Peter Jiang
2026-05-05 15:33:34 -04:00
committed by GitHub
co-authored by Alexy Mantha
parent 6de1ebc0b6
commit 030da147cc
9 changed files with 916 additions and 397 deletions
+4 -4
View File
@@ -29,7 +29,7 @@ importers:
version: 6.1.6([email protected]([email protected]))([email protected])
argo-ui:
specifier: git+https://github.com/argoproj/argo-ui.git
version: https://codeload.github.com/argoproj/argo-ui/tar.gz/a1c32a45e83fdda4baafc7ca3105c3ead383f8ba(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])([email protected])
version: https://codeload.github.com/argoproj/argo-ui/tar.gz/3677c93c2b2bb820cb3352e07d4d2caedee34e96(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])([email protected])
buffer:
specifier: ^6.0.3
version: 6.0.3
@@ -2173,8 +2173,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
argo-ui@https://codeload.github.com/argoproj/argo-ui/tar.gz/a1c32a45e83fdda4baafc7ca3105c3ead383f8ba:
resolution: {tarball: https://codeload.github.com/argoproj/argo-ui/tar.gz/a1c32a45e83fdda4baafc7ca3105c3ead383f8ba}
argo-ui@https://codeload.github.com/argoproj/argo-ui/tar.gz/3677c93c2b2bb820cb3352e07d4d2caedee34e96:
resolution: {tarball: https://codeload.github.com/argoproj/argo-ui/tar.gz/3677c93c2b2bb820cb3352e07d4d2caedee34e96}
version: 1.0.0
peerDependencies:
'@types/react': ^16.9.3
@@ -7879,7 +7879,7 @@ snapshots:
[email protected]: {}
argo-ui@https://codeload.github.com/argoproj/argo-ui/tar.gz/a1c32a45e83fdda4baafc7ca3105c3ead383f8ba(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])([email protected]):
argo-ui@https://codeload.github.com/argoproj/argo-ui/tar.gz/3677c93c2b2bb820cb3352e07d4d2caedee34e96(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])([email protected]):
dependencies:
'@fortawesome/fontawesome-free': 6.7.2
'@tippy.js/react': 3.1.1([email protected]([email protected]))([email protected])
+7 -1
View File
@@ -34,7 +34,7 @@ const routes: Routes = {
'/login': {component: login.component as any, noLayout: true},
'/applications': {component: applications.component},
// TODO: Uncomment when ApplicationSet details page is fully implemented
// '/applicationsets': {component: applications.component},
'/applicationsets': {component: applications.component},
'/settings': {component: settings.component},
'/user-info': {component: userInfo.component},
'/help': {component: help.component}
@@ -54,6 +54,12 @@ const navItems: NavItem[] = [
path: '/applications',
iconClassName: 'argo-icon argo-icon-application'
},
{
title: 'ApplicationSets',
tooltip: 'Manage your ApplicationSets, and diagnose health problems.',
path: '/applicationsets',
iconClassName: 'argo-icon argo-icon-applicationset'
},
{
title: 'Settings',
tooltip: 'Manage your repositories, projects, settings',
@@ -3,6 +3,7 @@ import {Route, RouteComponentProps, Switch} from 'react-router';
import {ApplicationDetails} from './application-details/application-details';
import {ApplicationFullscreenLogs} from './application-fullscreen-logs/application-fullscreen-logs';
import {ApplicationsList} from './applications-list/applications-list';
import {ApplicationSetsList} from './applications-list/application-sets-list';
export const ApplicationsContainer = (props: RouteComponentProps<any>) => {
// Determine objectListKind from the route path
@@ -10,7 +11,11 @@ export const ApplicationsContainer = (props: RouteComponentProps<any>) => {
return (
<Switch>
<Route exact={true} path={`${props.match.path}`} render={() => <ApplicationsList objectListKind={objectListKind} {...props} />} />
<Route
exact={true}
path={`${props.match.path}`}
render={() => (objectListKind === 'application' ? <ApplicationsList {...props} /> : <ApplicationSetsList {...props} />)}
/>
<Route exact={true} path={`${props.match.path}/:name`} render={routeProps => <ApplicationDetails objectListKind={objectListKind} {...routeProps} />} />
<Route exact={true} path={`${props.match.path}/:appnamespace/:name`} render={routeProps => <ApplicationDetails objectListKind={objectListKind} {...routeProps} />} />
<Route exact={true} path={`${props.match.path}/:name/:namespace/:container/logs`} component={ApplicationFullscreenLogs} />
@@ -0,0 +1,613 @@
import {Autocomplete, MockupList, Toolbar, Tooltip} from 'argo-ui';
import classNames from 'classnames';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import {Key, KeybindingContext, KeybindingProvider, NumKey, NumKeyToNumber, NumPadKey, useNav} from 'argo-ui/v2';
import {RouteComponentProps} from 'react-router';
import {combineLatest, from, merge, Observable} from 'rxjs';
import {bufferTime, delay, filter, map, mergeMap, repeat, retryWhen} from 'rxjs/operators';
import {AddAuthToToolbar, DataLoader, EmptyState, Page, Paginate} from '../../../shared/components';
import {AuthSettingsCtx, Consumer, Context, ContextApis} from '../../../shared/context';
import * as models from '../../../shared/models';
import {AppsListPreferences, AppsListViewKey, AppsListViewType, AppSetsListPreferences, HealthStatusBarPreferences, services, ViewPreferences} from '../../../shared/services';
import {useSidebarTarget} from '../../../sidebar/sidebar';
import {useObservableQuery, useQuery} from '../../../shared/hooks/query';
import * as AppUtils from '../utils';
import {AppSetsFilter, ApplicationSetFilteredApp, getAppSetFilterResults} from './applications-filter';
import {AppSetsStatusBar} from './applications-status-bar';
import {AppSetTile} from './appset-tile';
import {AppSetTableRow} from './appset-table-row';
import {ApplicationSetsSummary} from './application-sets-summary';
import './applications-list.scss';
import './applications-table.scss';
import './applications-tiles.scss';
import './flex-top-bar.scss';
const EVENTS_BUFFER_TIMEOUT = 500;
const WATCH_RETRY_TIMEOUT = 500;
const APPSET_FIELDS = [
'metadata.name',
'metadata.namespace',
'metadata.annotations',
'metadata.labels',
'metadata.creationTimestamp',
'metadata.deletionTimestamp',
'spec',
'status.conditions',
'status.resources',
'status.resourcesCount',
'status.health'
];
const APPSET_LIST_FIELDS = ['metadata.resourceVersion', ...APPSET_FIELDS.map(field => `items.${field}`)];
const APPSET_WATCH_FIELDS = ['result.type', ...APPSET_FIELDS.map(field => `result.applicationSet.${field}`)];
function loadApplicationSets(projects: string[]): Observable<models.ApplicationSet[]> {
return from(services.applications.list(projects, 'applicationset', {fields: APPSET_LIST_FIELDS})).pipe(
mergeMap(applicationsList => {
const appSets = applicationsList.items as models.ApplicationSet[];
return merge(
from([appSets]),
services.applications
.watch('applicationset', {projects, resourceVersion: applicationsList.metadata.resourceVersion}, {fields: APPSET_WATCH_FIELDS})
.pipe(repeat())
.pipe(retryWhen(errors => errors.pipe(delay(WATCH_RETRY_TIMEOUT))))
.pipe(bufferTime(EVENTS_BUFFER_TIMEOUT))
.pipe(
map(appChanges => {
appChanges.forEach(appChange => {
const appSet = appChange.application as unknown as models.ApplicationSet;
const index = appSets.findIndex(item => AppUtils.appInstanceName(item) === AppUtils.appInstanceName(appSet));
switch (appChange.type) {
case 'DELETED':
if (index > -1) {
appSets.splice(index, 1);
}
break;
default:
if (index > -1) {
appSets[index] = appSet;
} else {
appSets.unshift(appSet);
}
break;
}
});
return {appSets, updated: appChanges.length > 0};
})
)
.pipe(filter(item => item.updated))
.pipe(map(item => item.appSets))
);
})
);
}
const ViewPref = ({children}: {children: (pref: AppsListPreferences & {page: number; search: string}) => React.ReactNode}) => {
const observableQuery$ = useObservableQuery();
return (
<DataLoader
load={() =>
combineLatest([services.viewPreferences.getPreferences().pipe(map(item => item.appList)), observableQuery$]).pipe(
map(items => {
const params = items[1];
const viewPref: AppsListPreferences = {...items[0]};
if (params.get('health') != null) {
viewPref.healthFilter = params
.get('health')
.split(',')
.filter(item => !!item);
}
if (params.get('showFavorites') != null) {
viewPref.showFavorites = params.get('showFavorites') === 'true';
}
if (params.get('view') != null) {
viewPref.view = params.get('view') as AppsListViewType;
}
if (params.get('labels') != null) {
viewPref.labelsFilter = params
.get('labels')
.split(',')
.map(decodeURIComponent)
.filter(item => !!item);
}
return {...viewPref, page: parseInt(params.get('page') || '0', 10), search: params.get('search') || ''};
})
)
}>
{pref => children(pref)}
</DataLoader>
);
};
function filterApplicationSets(
appSets: models.ApplicationSet[],
pref: AppSetsListPreferences,
search: string
): {filteredApps: models.ApplicationSet[]; filterResults: ApplicationSetFilteredApp[]} {
const filterResults = getAppSetFilterResults(appSets, pref);
return {
filterResults,
filteredApps: filterResults.filter(
app => (search === '' || app.metadata.name.includes(search) || app.metadata.namespace.includes(search)) && Object.values(app.filterResult).every(val => val)
)
};
}
const ApplicationSetsSearchBar = (props: {content: string; ctx: ContextApis; appSets: models.ApplicationSet[]}) => {
const searchBar = React.useRef<HTMLDivElement>(null);
const {useKeybinding} = React.useContext(KeybindingContext);
const [isFocused, setFocus] = React.useState(false);
const useAuthSettingsCtx = React.useContext(AuthSettingsCtx);
useKeybinding({
keys: Key.SLASH,
action: () => {
if (searchBar.current) {
searchBar.current.querySelector('input').focus();
setFocus(true);
return true;
}
return false;
}
});
useKeybinding({
keys: Key.ESCAPE,
action: () => {
if (searchBar.current && isFocused) {
searchBar.current.querySelector('input').blur();
setFocus(false);
return true;
}
return false;
}
});
return (
<Autocomplete
filterSuggestions={true}
renderInput={inputProps => (
<div className='applications-list__search' ref={searchBar}>
<i
className='fa fa-search'
style={{marginRight: '9px', cursor: 'pointer'}}
onClick={() => {
if (searchBar.current) {
searchBar.current.querySelector('input').focus();
}
}}
/>
<input
{...inputProps}
onFocus={e => {
e.target.select();
if (inputProps.onFocus) {
inputProps.onFocus(e);
}
}}
style={{fontSize: '14px'}}
className='argo-field'
placeholder='Search application sets...'
/>
<div className='keyboard-hint'>/</div>
{props.content && (
<i className='fa fa-times' onClick={() => props.ctx.navigation.goto('.', {search: null}, {replace: true})} style={{cursor: 'pointer', marginLeft: '5px'}} />
)}
</div>
)}
wrapperProps={{className: 'applications-list__search-wrapper'}}
renderItem={item => (
<React.Fragment>
<i className='icon argo-icon-applicationset' /> {item.label}
</React.Fragment>
)}
onSelect={val => {
const selectedAppSet = props.appSets?.find(appSet => {
const qualifiedName = AppUtils.appQualifiedName(appSet, useAuthSettingsCtx?.appsInAnyNamespaceEnabled);
return qualifiedName === val;
});
if (selectedAppSet) {
props.ctx.navigation.goto(`/${AppUtils.getAppUrl(selectedAppSet)}`);
}
}}
onChange={e => props.ctx.navigation.goto('.', {search: e.target.value}, {replace: true})}
value={props.content || ''}
items={props.appSets.map(appSet => AppUtils.appQualifiedName(appSet, useAuthSettingsCtx?.appsInAnyNamespaceEnabled))}
/>
);
};
const ApplicationSetsToolbar = (props: {
appSets: models.ApplicationSet[];
pref: AppsListPreferences & {page: number; search: string};
ctx: ContextApis;
healthBarPrefs: HealthStatusBarPreferences;
}) => {
const {List, Summary, Tiles} = AppsListViewKey;
const query = useQuery();
return (
<React.Fragment key='appset-list-tools'>
<ApplicationSetsSearchBar content={query.get('search')} appSets={props.appSets} ctx={props.ctx} />
<Tooltip content='Toggle Health Status Bar'>
<button
className={`applications-list__accordion argo-button argo-button--base${props.healthBarPrefs.showHealthStatusBar ? '-o' : ''}`}
style={{border: 'none'}}
onClick={() => {
props.healthBarPrefs.showHealthStatusBar = !props.healthBarPrefs.showHealthStatusBar;
services.viewPreferences.updatePreferences({
appList: {
...props.pref,
statusBarView: {
...props.healthBarPrefs,
showHealthStatusBar: props.healthBarPrefs.showHealthStatusBar
}
}
});
}}>
<i className='fas fa-ruler-horizontal' />
</button>
</Tooltip>
<div className='applications-list__view-type' style={{marginLeft: 'auto'}}>
<i
className={classNames('fa fa-th', {selected: props.pref.view === Tiles}, 'menu_icon')}
title='Tiles'
onClick={() => {
props.ctx.navigation.goto('.', {view: Tiles});
services.viewPreferences.updatePreferences({appList: {...props.pref, view: Tiles}});
}}
/>
<i
className={classNames('fa fa-th-list', {selected: props.pref.view === List}, 'menu_icon')}
title='List'
onClick={() => {
props.ctx.navigation.goto('.', {view: List});
services.viewPreferences.updatePreferences({appList: {...props.pref, view: List}});
}}
/>
<i
className={classNames('fa fa-chart-pie', {selected: props.pref.view === Summary}, 'menu_icon')}
title='Summary'
onClick={() => {
props.ctx.navigation.goto('.', {view: Summary});
services.viewPreferences.updatePreferences({appList: {...props.pref, view: Summary}});
}}
/>
</div>
</React.Fragment>
);
};
const FlexTopBar = (props: {toolbar: Toolbar | Observable<Toolbar>}) => {
const ctx = React.useContext(Context);
const loadToolbar = AddAuthToToolbar(props.toolbar, ctx);
return (
<React.Fragment>
<div className='top-bar row flex-top-bar' key='tool-bar'>
<DataLoader load={() => loadToolbar}>
{toolbar => (
<React.Fragment>
<div className='flex-top-bar__actions'>
{toolbar.actionMenu && (
<React.Fragment>
{toolbar.actionMenu.items.map((item, i) => (
<Tooltip className='custom-tooltip' content={item.title} key={item.qeId || i}>
<button
disabled={!!item.disabled}
qe-id={item.qeId}
className='argo-button argo-button--base'
onClick={() => item.action()}
style={{marginRight: 2}}
key={i}>
{item.iconClassName && <i className={item.iconClassName} style={{marginLeft: '-5px', marginRight: '5px'}} />}
<span className='show-for-large'>{item.title}</span>
</button>
</Tooltip>
))}
</React.Fragment>
)}
</div>
<div className='flex-top-bar__tools'>{toolbar.tools}</div>
</React.Fragment>
)}
</DataLoader>
</div>
<div className='flex-top-bar__padder' />
</React.Fragment>
);
};
const useItemsPerContainer = (itemRef: any, containerRef: any): number => {
const [itemsPer, setItemsPer] = React.useState(0);
React.useEffect(() => {
const handleResize = () => {
let timeoutId: any;
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
timeoutId = null;
const itemWidth = itemRef.current ? itemRef.current.offsetWidth : -1;
const containerWidth = containerRef.current ? containerRef.current.offsetWidth : -1;
const curItemsPer = containerWidth > 0 && itemWidth > 0 ? Math.floor(containerWidth / itemWidth) : 1;
if (curItemsPer !== itemsPer) {
setItemsPer(curItemsPer);
}
}, 1000);
};
window.addEventListener('resize', handleResize);
handleResize();
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return itemsPer || 1;
};
const ApplicationSetTiles = ({appSets}: {appSets: models.ApplicationSet[]}) => {
const [selectedAppSet, navAppSet, reset] = useNav(appSets.length);
const ctxh = React.useContext(Context);
const firstTileRef = React.useRef<HTMLDivElement>(null);
const appSetContainerRef = React.useRef(null);
const appSetsPerRow = useItemsPerContainer(firstTileRef, appSetContainerRef);
const {useKeybinding} = React.useContext(KeybindingContext);
useKeybinding({keys: Key.RIGHT, action: () => navAppSet(1)});
useKeybinding({keys: Key.LEFT, action: () => navAppSet(-1)});
useKeybinding({keys: Key.DOWN, action: () => navAppSet(appSetsPerRow)});
useKeybinding({keys: Key.UP, action: () => navAppSet(-1 * appSetsPerRow)});
useKeybinding({
keys: Key.ENTER,
action: () => {
if (selectedAppSet > -1) {
ctxh.navigation.goto(`/${AppUtils.getAppUrl(appSets[selectedAppSet])}`);
return true;
}
return false;
}
});
useKeybinding({
keys: Key.ESCAPE,
action: () => {
if (selectedAppSet > -1) {
reset();
return true;
}
return false;
}
});
useKeybinding({
keys: Object.values(NumKey) as NumKey[],
action: n => {
reset();
return navAppSet(NumKeyToNumber(n));
}
});
useKeybinding({
keys: Object.values(NumPadKey) as NumPadKey[],
action: n => {
reset();
return navAppSet(NumKeyToNumber(n));
}
});
return (
<Consumer>
{ctx => (
<DataLoader load={() => services.viewPreferences.getPreferences()}>
{(pref: ViewPreferences) => (
<div className='applications-tiles argo-table-list argo-table-list--clickable' ref={appSetContainerRef}>
{appSets.map((appSet, i) => (
<AppSetTile
key={AppUtils.appInstanceName(appSet)}
appSet={appSet}
selected={selectedAppSet === i}
pref={pref}
ctx={ctx}
tileRef={i === 0 ? firstTileRef : undefined}
/>
))}
</div>
)}
</DataLoader>
)}
</Consumer>
);
};
const ApplicationSetTable = ({appSets}: {appSets: models.ApplicationSet[]}) => {
const [selectedAppSet, navAppSet, reset] = useNav(appSets.length);
const ctxh = React.useContext(Context);
const {useKeybinding} = React.useContext(KeybindingContext);
useKeybinding({keys: Key.DOWN, action: () => navAppSet(1)});
useKeybinding({keys: Key.UP, action: () => navAppSet(-1)});
useKeybinding({
keys: Key.ESCAPE,
action: () => {
reset();
return selectedAppSet > -1 ? true : false;
}
});
useKeybinding({
keys: Key.ENTER,
action: () => {
if (selectedAppSet > -1) {
ctxh.navigation.goto(`/${AppUtils.getAppUrl(appSets[selectedAppSet])}`);
return true;
}
return false;
}
});
return (
<Consumer>
{ctx => (
<DataLoader load={() => services.viewPreferences.getPreferences()}>
{(pref: ViewPreferences) => (
<div className='applications-table argo-table-list argo-table-list--clickable'>
{appSets.map((appSet, i) => (
<AppSetTableRow key={AppUtils.appInstanceName(appSet)} appSet={appSet} selected={selectedAppSet === i} pref={pref} ctx={ctx} />
))}
</div>
)}
</DataLoader>
)}
</Consumer>
);
};
export const ApplicationSetsList = (props: RouteComponentProps<any>) => {
const {List, Summary, Tiles} = AppsListViewKey;
const sidebarTarget = useSidebarTarget();
function onAppSetFilterPrefChanged(ctx: ContextApis, newPref: AppSetsListPreferences) {
services.viewPreferences.updatePreferences({appList: newPref as AppsListPreferences});
ctx.navigation.goto(
'.',
{
health: newPref.healthFilter.join(','),
labels: newPref.labelsFilter.map(encodeURIComponent).join(','),
showFavorites: newPref.showFavorites ? 'true' : null
},
{replace: true}
);
}
function getPageTitle(view: string) {
switch (view) {
case List:
return 'ApplicationSets List';
case Tiles:
return 'ApplicationSets Tiles';
case Summary:
return 'ApplicationSets Summary';
}
return '';
}
return (
<KeybindingProvider>
<Consumer>
{ctx => (
<ViewPref>
{pref => (
<Page
key={pref.view}
title={getPageTitle(pref.view)}
useTitleOnly={true}
toolbar={{breadcrumbs: [{title: 'ApplicationSets', path: props.match.url}]}}
hideAuth={true}>
<DataLoader
input={pref.projectsFilter?.join(',')}
load={() => AppUtils.handlePageVisibility(() => loadApplicationSets(pref.projectsFilter))}
loadingRenderer={() => (
<div className='argo-container'>
<MockupList height={100} marginTop={30} />
</div>
)}>
{(appSets: models.ApplicationSet[]) => {
const healthBarPrefs = pref.statusBarView || ({} as HealthStatusBarPreferences);
const appSetPref: AppSetsListPreferences = {
labelsFilter: pref.labelsFilter,
healthFilter: pref.healthFilter,
showFavorites: pref.showFavorites,
favoritesAppList: pref.favoritesAppList,
view: pref.view,
hideFilters: pref.hideFilters,
statusBarView: pref.statusBarView,
annotationsFilter: pref.annotationsFilter
};
const {filteredApps, filterResults} = filterApplicationSets(appSets, appSetPref, pref.search);
return (
<React.Fragment>
<FlexTopBar
toolbar={{
tools: <ApplicationSetsToolbar appSets={appSets} pref={pref} ctx={ctx} healthBarPrefs={healthBarPrefs} />,
actionMenu: {
items: []
}
}}
/>
<div className='applications-list'>
{appSets.length === 0 && (pref.labelsFilter || []).length === 0 ? (
<EmptyState icon='argo-icon-applicationset'>
<h4>No ApplicationSets available to you just yet</h4>
<h5>ApplicationSets will appear here once created</h5>
</EmptyState>
) : (
<>
{ReactDOM.createPortal(
<DataLoader load={() => services.viewPreferences.getPreferences()}>
{allpref => (
<AppSetsFilter
apps={filterResults}
onChange={newPrefs => onAppSetFilterPrefChanged(ctx, newPrefs)}
pref={appSetPref}
collapsed={allpref.hideSidebar}
/>
)}
</DataLoader>,
sidebarTarget?.current
)}
{pref.view === Summary ? (
<ApplicationSetsSummary appSets={filteredApps} />
) : (
<Paginate
header={filteredApps.length > 1 && <AppSetsStatusBar appSets={filteredApps} />}
showHeader={healthBarPrefs.showHealthStatusBar}
preferencesKey='applications-list'
page={pref.page}
emptyState={() => (
<EmptyState icon='fa fa-search'>
<h4>No matching application sets found</h4>
<h5>
Change filter criteria or&nbsp;
<a
onClick={() => {
AppSetsListPreferences.clearFilters(appSetPref);
onAppSetFilterPrefChanged(ctx, appSetPref);
}}>
clear filters
</a>
</h5>
</EmptyState>
)}
sortOptions={[
{
title: 'Name',
compare: (a, b) => a.metadata.name.localeCompare(b.metadata.name, undefined, {numeric: true})
},
{
title: 'Created At',
compare: (b, a) => a.metadata.creationTimestamp.localeCompare(b.metadata.creationTimestamp)
}
]}
data={filteredApps}
onPageChange={page => ctx.navigation.goto('.', {page})}>
{data =>
(pref.view === Tiles && <ApplicationSetTiles appSets={data} />) || <ApplicationSetTable appSets={data} />
}
</Paginate>
)}
</>
)}
</div>
</React.Fragment>
);
}}
</DataLoader>
</Page>
)}
</ViewPref>
)}
</Consumer>
</KeybindingProvider>
);
};
@@ -0,0 +1,87 @@
import * as React from 'react';
const PieChart = require('react-svg-piechart').default;
import {COLORS} from '../../../shared/components';
import * as models from '../../../shared/models';
import {HealthStatusCode} from '../../../shared/models';
import {HealthStatusIcon, getAppSetHealthStatus} from '../utils';
const appSetHealthColors = new Map<models.HealthStatusCode, string>();
appSetHealthColors.set('Unknown', COLORS.health.unknown);
appSetHealthColors.set('Healthy', COLORS.health.healthy);
appSetHealthColors.set('Degraded', COLORS.health.degraded);
appSetHealthColors.set('Progressing', COLORS.health.progressing);
export const ApplicationSetsSummary = ({appSets}: {appSets: models.ApplicationSet[]}) => {
const health = new Map<string, number>();
appSets.forEach(appSet => {
const status = getAppSetHealthStatus(appSet);
health.set(status, (health.get(status) || 0) + 1);
});
const attributes = [
{title: 'APPLICATIONSETS', value: appSets.length},
{title: 'HEALTHY', value: appSets.filter(appSet => getAppSetHealthStatus(appSet) === 'Healthy').length}
];
const charts = [
{
title: 'Health',
data: Array.from(health.keys()).map(key => ({title: key, value: health.get(key), color: appSetHealthColors.get(key as models.HealthStatusCode)})),
legend: appSetHealthColors as Map<string, string>
}
];
return (
<div className='white-box applications-list__summary'>
<div className='row'>
<div className='columns large-3 small-12'>
<div className='white-box__details'>
<p className='row'>SUMMARY</p>
{attributes.map(attr => (
<div className='row white-box__details-row' key={attr.title}>
<div className='columns small-8'>{attr.title}</div>
<div style={{textAlign: 'right'}} className='columns small-4'>
{attr.value}
</div>
</div>
))}
</div>
</div>
<div className='columns large-9 small-12'>
<div className='row chart-group'>
{charts.map(chart => {
const getLegendValue = (key: string) => {
const index = chart.data.findIndex((data: {title: string}) => data.title === key);
return index > -1 ? chart.data[index].value : 0;
};
return (
<React.Fragment key={chart.title}>
<div className='columns large-6 small-12'>
<div className='row chart'>
<div className='large-8 small-6'>
<h4 style={{textAlign: 'center'}}>{chart.title}</h4>
<PieChart data={chart.data} />
</div>
<div className='large-3 small-1'>
<ul>
{Array.from(chart.legend.keys()).map(key => (
<li style={{listStyle: 'none', whiteSpace: 'nowrap'}} key={key}>
<HealthStatusIcon state={{status: key as HealthStatusCode, message: ''}} noSpin={true} />
{` ${key} (${getLegendValue(key)})`}
</li>
))}
</ul>
</div>
</div>
</div>
</React.Fragment>
);
})}
</div>
</div>
</div>
</div>
);
};
@@ -9,13 +9,13 @@ import {bufferTime, delay, filter, map, mergeMap, repeat, retryWhen} from 'rxjs/
import {AddAuthToToolbar, ClusterCtx, DataLoader, EmptyState, Page, Paginate, Spinner} from '../../../shared/components';
import {AuthSettingsCtx, Consumer, Context, ContextApis} from '../../../shared/context';
import * as models from '../../../shared/models';
import {AppsListViewKey, AppsListPreferences, AppSetsListPreferences, AppsListViewType, HealthStatusBarPreferences, services} from '../../../shared/services';
import {AppsListViewKey, AppsListPreferences, AppsListViewType, HealthStatusBarPreferences, services} from '../../../shared/services';
import {ApplicationCreatePanel} from '../application-create-panel/application-create-panel';
import {ApplicationSyncPanel} from '../application-sync-panel/application-sync-panel';
import {ApplicationsSyncPanel} from '../applications-sync-panel/applications-sync-panel';
import * as AppUtils from '../utils';
import {ApplicationsFilter, AppSetsFilter, FilteredApp, ApplicationSetFilteredApp, getAppFilterResults, getAppSetFilterResults} from './applications-filter';
import {AppsStatusBar, AppSetsStatusBar} from './applications-status-bar';
import {ApplicationsFilter, FilteredApp, getAppFilterResults} from './applications-filter';
import {AppsStatusBar} from './applications-status-bar';
import {ApplicationsSummary} from './applications-summary';
import {ApplicationsTable} from './applications-table';
import {ApplicationTiles} from './applications-tiles';
@@ -53,34 +53,14 @@ const APP_FIELDS = [
const APP_LIST_FIELDS = ['metadata.resourceVersion', ...APP_FIELDS.map(field => `items.${field}`)];
const APP_WATCH_FIELDS = ['result.type', ...APP_FIELDS.map(field => `result.application.${field}`)];
// ApplicationSet has different status fields than Application
const APPSET_FIELDS = [
'metadata.name',
'metadata.namespace',
'metadata.annotations',
'metadata.labels',
'metadata.creationTimestamp',
'metadata.deletionTimestamp',
'spec',
'status.conditions',
'status.resources',
'status.resourcesCount',
'status.health'
];
const APPSET_LIST_FIELDS = ['metadata.resourceVersion', ...APPSET_FIELDS.map(field => `items.${field}`)];
const APPSET_WATCH_FIELDS = ['result.type', ...APPSET_FIELDS.map(field => `result.applicationSet.${field}`)];
function loadApplications(projects: string[], appNamespace: string, objectListKind: string): Observable<models.AbstractApplication[]> {
const isApplication = objectListKind === 'application';
const listFields = isApplication ? APP_LIST_FIELDS : APPSET_LIST_FIELDS;
const watchFields = isApplication ? APP_WATCH_FIELDS : APPSET_WATCH_FIELDS;
return from(services.applications.list(projects, objectListKind, {appNamespace, fields: listFields})).pipe(
function loadApplications(projects: string[], appNamespace: string): Observable<models.Application[]> {
return from(services.applications.list(projects, 'application', {appNamespace, fields: APP_LIST_FIELDS})).pipe(
mergeMap(applicationsList => {
const applications = applicationsList.items;
const applications = applicationsList.items as models.Application[];
return merge(
from([applications]),
services.applications
.watch(objectListKind, {projects, resourceVersion: applicationsList.metadata.resourceVersion}, {fields: watchFields})
.watch('application', {projects, resourceVersion: applicationsList.metadata.resourceVersion}, {fields: APP_WATCH_FIELDS})
.pipe(repeat())
.pipe(retryWhen(errors => errors.pipe(delay(WATCH_RETRY_TIMEOUT))))
// batch events to avoid constant re-rendering and improve UI performance
@@ -232,21 +212,6 @@ function filterApplications(applications: models.Application[], pref: AppsListPr
};
}
function filterApplicationSets(
appSets: models.ApplicationSet[],
pref: AppSetsListPreferences,
search: string
): {filteredApps: models.ApplicationSet[]; filterResults: ApplicationSetFilteredApp[]} {
const filterResults = getAppSetFilterResults(appSets, pref);
return {
filterResults,
filteredApps: filterResults.filter(
app => (search === '' || app.metadata.name.includes(search) || app.metadata.namespace.includes(search)) && Object.values(app.filterResult).every(val => val)
)
};
}
function tryJsonParse(input: string) {
try {
return (input && JSON.parse(input)) || null;
@@ -255,8 +220,8 @@ function tryJsonParse(input: string) {
}
}
const SearchBar = (props: {content: string; ctx: ContextApis; apps: models.AbstractApplication[]; isListOfApplications: boolean}) => {
const {content, ctx, apps, isListOfApplications} = {...props};
const SearchBar = (props: {content: string; ctx: ContextApis; apps: models.Application[]}) => {
const {content, ctx, apps} = {...props};
const searchBar = React.useRef<HTMLDivElement>(null);
@@ -315,7 +280,7 @@ const SearchBar = (props: {content: string; ctx: ContextApis; apps: models.Abstr
}}
style={{fontSize: '14px'}}
className='argo-field'
placeholder={isListOfApplications ? 'Search applications...' : 'Search application sets...'}
placeholder='Search applications...'
/>
<div className='keyboard-hint'>/</div>
{content && (
@@ -346,20 +311,19 @@ const SearchBar = (props: {content: string; ctx: ContextApis; apps: models.Abstr
};
interface ApplicationsToolbarProps {
applications: models.AbstractApplication[];
applications: models.Application[];
pref: AppsListPreferences & {page: number; search: string};
ctx: ContextApis;
healthBarPrefs: HealthStatusBarPreferences;
isListOfApplications: boolean;
}
const ApplicationsToolbar: React.FC<ApplicationsToolbarProps> = ({applications, pref, ctx, healthBarPrefs, isListOfApplications}) => {
const ApplicationsToolbar: React.FC<ApplicationsToolbarProps> = ({applications, pref, ctx, healthBarPrefs}) => {
const {List, Summary, Tiles} = AppsListViewKey;
const query = useQuery();
return (
<React.Fragment key='app-list-tools'>
<SearchBar content={query.get('search')} apps={applications} ctx={ctx} isListOfApplications={isListOfApplications} />
<SearchBar content={query.get('search')} apps={applications} ctx={ctx} />
<Tooltip content='Toggle Health Status Bar'>
<button
className={`applications-list__accordion argo-button argo-button--base${healthBarPrefs.showHealthStatusBar ? '-o' : ''}`}
@@ -448,7 +412,7 @@ const FlexTopBar = (props: {toolbar: Toolbar | Observable<Toolbar>}) => {
);
};
export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKind: string}) => {
export const ApplicationsList = (props: RouteComponentProps<any>) => {
const query = useQuery();
const observableQuery$ = useObservableQuery();
const appInput = tryJsonParse(query.get('new'));
@@ -460,9 +424,6 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
const loaderRef = React.useRef<DataLoader>();
const {List, Summary, Tiles} = AppsListViewKey;
const objectListKind = props.objectListKind;
const isListOfApplications = objectListKind === 'application';
function refreshApp(appName: string, appNamespace: string) {
// app refreshing might be done too quickly so that UI might miss it due to event batching
// add refreshing annotation in the UI to improve user experience
@@ -474,7 +435,7 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
loaderRef.current.setData(applications);
}
}
services.applications.get(appName, appNamespace, objectListKind, 'normal');
services.applications.get(appName, appNamespace, 'application', 'normal');
}
function onAppFilterPrefChanged(ctx: ContextApis, newPref: AppsListPreferences) {
@@ -500,30 +461,14 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
);
}
function onAppSetFilterPrefChanged(ctx: ContextApis, newPref: AppSetsListPreferences) {
// Use appList since ViewPreferences shares preferences between apps and appsets
services.viewPreferences.updatePreferences({appList: newPref as AppsListPreferences});
ctx.navigation.goto(
'.',
{
health: newPref.healthFilter.join(','),
labels: newPref.labelsFilter.map(encodeURIComponent).join(','),
// Keep URL and preferences consistent. When false, remove the param entirely.
showFavorites: newPref.showFavorites ? 'true' : null
},
{replace: true}
);
}
function getPageTitle(view: string) {
const entityName = isListOfApplications ? 'Applications' : 'ApplicationSets';
switch (view) {
case List:
return `${entityName} List`;
return 'Applications List';
case Tiles:
return `${entityName} Tiles`;
return 'Applications Tiles';
case Summary:
return `${entityName} Summary`;
return 'Applications Summary';
}
return '';
}
@@ -544,8 +489,8 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
toolbar={{
breadcrumbs: [
{
title: isListOfApplications ? 'Applications' : 'ApplicationSets',
path: isListOfApplications ? '/applications' : '/applicationsets'
title: 'Applications',
path: props.match.url
}
]
}}
@@ -553,13 +498,13 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
<DataLoader
input={pref.projectsFilter?.join(',')}
ref={loaderRef}
load={() => AppUtils.handlePageVisibility(() => loadApplications(pref.projectsFilter, query.get('appNamespace'), objectListKind))}
load={() => AppUtils.handlePageVisibility(() => loadApplications(pref.projectsFilter, query.get('appNamespace')))}
loadingRenderer={() => (
<div className='argo-container'>
<MockupList height={100} marginTop={30} />
</div>
)}>
{(applications: models.AbstractApplication[]) => {
{(applications: models.Application[]) => {
const healthBarPrefs = pref.statusBarView || ({} as HealthStatusBarPreferences);
const handleCreatePanelClose = async () => {
const outsideDiv = document.querySelector('.sliding-panel__outside');
@@ -576,289 +521,79 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
}
};
if (isListOfApplications) {
// Applications path - fully type-safe
const apps = applications as models.Application[];
const {filteredApps, filterResults} = filterApplications(apps, pref, pref.search);
const apps = applications as models.Application[];
const {filteredApps, filterResults} = filterApplications(apps, pref, pref.search);
return (
<React.Fragment>
<FlexTopBar
toolbar={{
tools: (
<ApplicationsToolbar
applications={applications}
pref={pref}
ctx={ctx}
healthBarPrefs={healthBarPrefs}
isListOfApplications={isListOfApplications}
/>
),
actionMenu: {
items: [
{
title: 'New App',
iconClassName: 'fa fa-plus',
qeId: 'applications-list-button-new-app',
action: () => ctx.navigation.goto('.', {new: '{}'}, {replace: true})
},
{
title: 'Sync Apps',
iconClassName: 'fa fa-sync',
action: () => ctx.navigation.goto('.', {syncApps: true}, {replace: true})
},
{
title: 'Refresh Apps',
iconClassName: 'fa fa-redo',
action: () => ctx.navigation.goto('.', {refreshApps: true}, {replace: true})
}
]
}
}}
/>
<div className='applications-list'>
{apps.length === 0 && pref.projectsFilter?.length === 0 && (pref.labelsFilter || []).length === 0 ? (
<EmptyState icon='argo-icon-application'>
<h4>No applications available to you just yet</h4>
<h5>Create new application to start managing resources in your cluster</h5>
<button
qe-id='applications-list-button-create-application'
className='argo-button argo-button--base'
onClick={() => ctx.navigation.goto('.', {new: JSON.stringify({})}, {replace: true})}>
Create application
</button>
</EmptyState>
) : (
<>
{ReactDOM.createPortal(
<DataLoader load={() => services.viewPreferences.getPreferences()}>
{allpref => (
<ApplicationsFilter
apps={filterResults}
onChange={newPrefs => onAppFilterPrefChanged(ctx, newPrefs)}
pref={pref}
collapsed={allpref.hideSidebar}
/>
)}
</DataLoader>,
sidebarTarget?.current
)}
{(pref.view === 'summary' && <ApplicationsSummary applications={filteredApps} />) || (
<Paginate
header={filteredApps.length > 1 && <AppsStatusBar applications={filteredApps} />}
showHeader={healthBarPrefs.showHealthStatusBar}
preferencesKey='applications-list'
page={pref.page}
emptyState={() => (
<EmptyState icon='fa fa-search'>
<h4>No matching applications found</h4>
<h5>
Change filter criteria or&nbsp;
<a
onClick={() => {
AppsListPreferences.clearFilters(pref);
onAppFilterPrefChanged(ctx, pref);
}}>
clear filters
</a>
</h5>
</EmptyState>
)}
sortOptions={[
{
title: 'Name',
compare: (a, b) => a.metadata.name.localeCompare(b.metadata.name, undefined, {numeric: true})
},
{
title: 'Created At',
compare: (b, a) => a.metadata.creationTimestamp.localeCompare(b.metadata.creationTimestamp)
},
{
title: 'Synchronized',
compare: (b, a) =>
a.status.operationState?.finishedAt?.localeCompare(b.status.operationState?.finishedAt)
}
]}
data={filteredApps}
onPageChange={page => ctx.navigation.goto('.', {page})}>
{data =>
(pref.view === 'tiles' && (
<ApplicationTiles
applications={data}
syncApplication={(appName, appNamespace) =>
ctx.navigation.goto('.', {syncApp: appName, appNamespace}, {replace: true})
}
refreshApplication={refreshApp}
deleteApplication={(appName, appNamespace) =>
AppUtils.deleteApplication(appName, appNamespace, ctx)
}
/>
)) || (
<ApplicationsTable
applications={data}
syncApplication={(appName, appNamespace) =>
ctx.navigation.goto('.', {syncApp: appName, appNamespace}, {replace: true})
}
refreshApplication={refreshApp}
deleteApplication={(appName, appNamespace) =>
AppUtils.deleteApplication(appName, appNamespace, ctx)
}
/>
)
}
</Paginate>
)}
</>
)}
<ApplicationsSyncPanel
key='syncsPanel'
show={syncAppsInput}
hide={() => ctx.navigation.goto('.', {syncApps: null}, {replace: true})}
apps={filteredApps}
/>
<ApplicationsRefreshPanel
key='refreshPanel'
show={refreshAppsInput}
hide={() => ctx.navigation.goto('.', {refreshApps: null}, {replace: true})}
apps={filteredApps}
/>
</div>
<DataLoader
load={() =>
observableQuery$.pipe(
mergeMap(params => {
const syncApp = params.get('syncApp');
const appNamespace = params.get('appNamespace');
return (syncApp && from(services.applications.get(syncApp, appNamespace, objectListKind))) || from([null]);
})
)
}>
{app => (
<ApplicationSyncPanel
key='syncPanel'
application={app}
selectedResource={'all'}
hide={() => ctx.navigation.goto('.', {syncApp: null}, {replace: true})}
/>
)}
</DataLoader>
<SlidingPanel
isShown={!!appInput}
onClose={() => handleCreatePanelClose()}
header={
<div>
<button
qe-id='applications-list-button-create'
className='argo-button argo-button--base'
disabled={isAppCreatePending}
onClick={() => createApi && createApi.submitForm(null)}>
<Spinner show={isAppCreatePending} style={{marginRight: '5px'}} />
Create
</button>{' '}
<button
qe-id='applications-list-button-cancel'
onClick={() => ctx.navigation.goto('.', {new: null}, {replace: true})}
className='argo-button argo-button--base-o'>
Cancel
</button>
</div>
}>
{appInput && (
<ApplicationCreatePanel
getFormApi={api => {
setCreateApi(api);
}}
createApp={async app => {
setAppCreatePending(true);
try {
await services.applications.create(app);
ctx.navigation.goto('.', {new: null}, {replace: true});
} catch (e) {
ctx.notifications.show({
content: <ErrorNotification title='Unable to create application' e={e} />,
type: NotificationType.Error
});
} finally {
setAppCreatePending(false);
}
}}
app={appInput}
onAppChanged={app => ctx.navigation.goto('.', {new: JSON.stringify(app)}, {replace: true})}
/>
)}
</SlidingPanel>
</React.Fragment>
);
} else {
// ApplicationSets path - fully type-safe
const appSets = applications as models.ApplicationSet[];
const appSetPref: AppSetsListPreferences = {
labelsFilter: pref.labelsFilter,
healthFilter: pref.healthFilter,
showFavorites: pref.showFavorites,
favoritesAppList: pref.favoritesAppList,
view: pref.view,
hideFilters: pref.hideFilters,
statusBarView: pref.statusBarView,
annotationsFilter: pref.annotationsFilter
};
const {filteredApps, filterResults} = filterApplicationSets(appSets, appSetPref, pref.search);
return (
<React.Fragment>
<FlexTopBar
toolbar={{
tools: (
<ApplicationsToolbar
applications={applications}
pref={pref}
ctx={ctx}
healthBarPrefs={healthBarPrefs}
isListOfApplications={isListOfApplications}
/>
),
actionMenu: {
items: [] // No action menu for ApplicationSets yet
}
}}
/>
<div className='applications-list'>
{appSets.length === 0 && (pref.labelsFilter || []).length === 0 ? (
<EmptyState icon='argo-icon-application'>
<h4>No ApplicationSets available to you just yet</h4>
<h5>ApplicationSets will appear here once created</h5>
</EmptyState>
) : (
<>
{ReactDOM.createPortal(
<DataLoader load={() => services.viewPreferences.getPreferences()}>
{allpref => (
<AppSetsFilter
apps={filterResults}
onChange={newPrefs => onAppSetFilterPrefChanged(ctx, newPrefs)}
pref={appSetPref}
collapsed={allpref.hideSidebar}
/>
)}
</DataLoader>,
sidebarTarget?.current
)}
return (
<React.Fragment>
<FlexTopBar
toolbar={{
tools: <ApplicationsToolbar applications={applications} pref={pref} ctx={ctx} healthBarPrefs={healthBarPrefs} />,
actionMenu: {
items: [
{
title: 'New App',
iconClassName: 'fa fa-plus',
qeId: 'applications-list-button-new-app',
action: () => ctx.navigation.goto('.', {new: '{}'}, {replace: true})
},
{
title: 'Sync Apps',
iconClassName: 'fa fa-sync',
action: () => ctx.navigation.goto('.', {syncApps: true}, {replace: true})
},
{
title: 'Refresh Apps',
iconClassName: 'fa fa-redo',
action: () => ctx.navigation.goto('.', {refreshApps: true}, {replace: true})
}
]
}
}}
/>
<div className='applications-list'>
{apps.length === 0 && pref.projectsFilter?.length === 0 && (pref.labelsFilter || []).length === 0 ? (
<EmptyState icon='argo-icon-application'>
<h4>No applications available to you just yet</h4>
<h5>Create new application to start managing resources in your cluster</h5>
<button
qe-id='applications-list-button-create-application'
className='argo-button argo-button--base'
onClick={() => ctx.navigation.goto('.', {new: JSON.stringify({})}, {replace: true})}>
Create application
</button>
</EmptyState>
) : (
<>
{ReactDOM.createPortal(
<DataLoader load={() => services.viewPreferences.getPreferences()}>
{allpref => (
<ApplicationsFilter
apps={filterResults}
onChange={newPrefs => onAppFilterPrefChanged(ctx, newPrefs)}
pref={pref}
collapsed={allpref.hideSidebar}
/>
)}
</DataLoader>,
sidebarTarget?.current
)}
{(pref.view === 'summary' && <ApplicationsSummary applications={filteredApps} />) || (
<Paginate
header={filteredApps.length > 1 && <AppSetsStatusBar appSets={filteredApps} />}
header={filteredApps.length > 1 && <AppsStatusBar applications={filteredApps} />}
showHeader={healthBarPrefs.showHealthStatusBar}
preferencesKey='applications-list'
page={pref.page}
emptyState={() => (
<EmptyState icon='fa fa-search'>
<h4>No matching application sets found</h4>
<h4>No matching applications found</h4>
<h5>
Change filter criteria or&nbsp;
<a
onClick={() => {
AppSetsListPreferences.clearFilters(appSetPref);
onAppSetFilterPrefChanged(ctx, appSetPref);
AppsListPreferences.clearFilters(pref);
onAppFilterPrefChanged(ctx, pref);
}}>
clear filters
</a>
@@ -873,6 +608,11 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
{
title: 'Created At',
compare: (b, a) => a.metadata.creationTimestamp.localeCompare(b.metadata.creationTimestamp)
},
{
title: 'Synchronized',
compare: (b, a) =>
a.status.operationState?.finishedAt?.localeCompare(b.status.operationState?.finishedAt)
}
]}
data={filteredApps}
@@ -881,26 +621,110 @@ export const ApplicationsList = (props: RouteComponentProps<any> & {objectListKi
(pref.view === 'tiles' && (
<ApplicationTiles
applications={data}
syncApplication={() => {}}
refreshApplication={() => {}}
deleteApplication={() => {}}
syncApplication={(appName, appNamespace) =>
ctx.navigation.goto('.', {syncApp: appName, appNamespace}, {replace: true})
}
refreshApplication={refreshApp}
deleteApplication={(appName, appNamespace) =>
AppUtils.deleteApplication(appName, appNamespace, ctx)
}
/>
)) || (
<ApplicationsTable
applications={data}
syncApplication={() => {}}
refreshApplication={() => {}}
deleteApplication={() => {}}
syncApplication={(appName, appNamespace) =>
ctx.navigation.goto('.', {syncApp: appName, appNamespace}, {replace: true})
}
refreshApplication={refreshApp}
deleteApplication={(appName, appNamespace) =>
AppUtils.deleteApplication(appName, appNamespace, ctx)
}
/>
)
}
</Paginate>
</>
)}
</div>
</React.Fragment>
);
}
)}
</>
)}
<ApplicationsSyncPanel
key='syncsPanel'
show={syncAppsInput}
hide={() => ctx.navigation.goto('.', {syncApps: null}, {replace: true})}
apps={filteredApps}
/>
<ApplicationsRefreshPanel
key='refreshPanel'
show={refreshAppsInput}
hide={() => ctx.navigation.goto('.', {refreshApps: null}, {replace: true})}
apps={filteredApps}
/>
</div>
<DataLoader
load={() =>
observableQuery$.pipe(
mergeMap(params => {
const syncApp = params.get('syncApp');
const appNamespace = params.get('appNamespace');
return (syncApp && from(services.applications.get(syncApp, appNamespace, 'application'))) || from([null]);
})
)
}>
{app => (
<ApplicationSyncPanel
key='syncPanel'
application={app}
selectedResource={'all'}
hide={() => ctx.navigation.goto('.', {syncApp: null}, {replace: true})}
/>
)}
</DataLoader>
<SlidingPanel
isShown={!!appInput}
onClose={() => handleCreatePanelClose()}
header={
<div>
<button
qe-id='applications-list-button-create'
className='argo-button argo-button--base'
disabled={isAppCreatePending}
onClick={() => createApi && createApi.submitForm(null)}>
<Spinner show={isAppCreatePending} style={{marginRight: '5px'}} />
Create
</button>{' '}
<button
qe-id='applications-list-button-cancel'
onClick={() => ctx.navigation.goto('.', {new: null}, {replace: true})}
className='argo-button argo-button--base-o'>
Cancel
</button>
</div>
}>
{appInput && (
<ApplicationCreatePanel
getFormApi={api => {
setCreateApi(api);
}}
createApp={async app => {
setAppCreatePending(true);
try {
await services.applications.create(app);
ctx.navigation.goto('.', {new: null}, {replace: true});
} catch (e) {
ctx.notifications.show({
content: <ErrorNotification title='Unable to create application' e={e} />,
type: NotificationType.Error
});
} finally {
setAppCreatePending(false);
}
}}
app={appInput}
onAppChanged={app => ctx.navigation.goto('.', {new: JSON.stringify(app)}, {replace: true})}
/>
)}
</SlidingPanel>
</React.Fragment>
);
}}
</DataLoader>
</Page>
@@ -37,30 +37,12 @@ export const ApplicationsSummary = ({applications}: {applications: models.Applic
});
const attributes = [
{
title: 'APPLICATIONS',
value: applications.length
},
{
title: 'SYNCED',
value: applications.filter(app => app.status.sync.status === 'Synced').length
},
{
title: 'HEALTHY',
value: applications.filter(app => app.status.health.status === 'Healthy').length
},
{
title: 'HYDRATED',
value: applications.filter(app => app.status.sourceHydrator?.currentOperation?.phase === 'Hydrated').length
},
{
title: 'CLUSTERS',
value: new Set(applications.map(app => app.spec.destination.server || app.spec.destination.name)).size
},
{
title: 'NAMESPACES',
value: new Set(applications.map(app => app.spec.destination.namespace)).size
}
{title: 'APPLICATIONS', value: applications.length},
{title: 'SYNCED', value: applications.filter(app => app.status.sync.status === 'Synced').length},
{title: 'HEALTHY', value: applications.filter(app => app.status.health.status === 'Healthy').length},
{title: 'HYDRATED', value: applications.filter(app => app.status.sourceHydrator?.currentOperation?.phase === 'Hydrated').length},
{title: 'CLUSTERS', value: new Set(applications.map(app => app.spec.destination.server || app.spec.destination.name)).size},
{title: 'NAMESPACES', value: new Set(applications.map(app => app.spec.destination.namespace)).size}
];
const charts = [
@@ -80,6 +62,7 @@ export const ApplicationsSummary = ({applications}: {applications: models.Applic
legend: hydratorColors as Map<string, string>
}
];
return (
<div className='white-box applications-list__summary'>
<div className='row'>
@@ -142,5 +142,12 @@ describe('ResourceIcon', () => {
const icons = testInstance.findAll(node => node.type === 'i' && typeof node.props.className === 'string' && node.props.className.includes('argo-icon-application'));
expect(icons.length).toBeGreaterThan(0);
});
it('should show applicationset icon for kind=ApplicationSet', () => {
const testRenderer = renderer.create(<ResourceIcon group='argoproj.io' kind='ApplicationSet' />);
const testInstance = testRenderer.root;
const icons = testInstance.findAll(node => node.type === 'i' && typeof node.props.className === 'string' && node.props.className.includes('argo-icon-applicationset'));
expect(icons.length).toBeGreaterThan(0);
});
});
});
@@ -11,13 +11,7 @@ export const ResourceIcon = ({group, kind, customStyle}: {group: string; kind: s
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>
);
return <i title={kind} className={`icon argo-icon-applicationset`} style={customStyle} />;
}
// First, check for group-based custom icons
if (group) {