mirror of
https://github.com/hanzoai/deploy.git
synced 2026-10-07 08:44:50 +00:00
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:
committed by
GitHub
co-authored by
Alexy Mantha
parent
6de1ebc0b6
commit
030da147cc
Generated
+4
-4
@@ -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
@@ -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
|
||||
<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
|
||||
<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
|
||||
<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) {
|
||||
|
||||
Reference in New Issue
Block a user