argo-cd
656 строк · 42.1 Кб
1import {Autocomplete, ErrorNotification, MockupList, NotificationType, SlidingPanel, Toolbar, Tooltip} from 'argo-ui';
2import * as classNames from 'classnames';
3import * as React from 'react';
4import * as ReactDOM from 'react-dom';
5import {Key, KeybindingContext, KeybindingProvider} from 'argo-ui/v2';
6import {RouteComponentProps} from 'react-router';
7import {combineLatest, from, merge, Observable} from 'rxjs';
8import {bufferTime, delay, filter, map, mergeMap, repeat, retryWhen} from 'rxjs/operators';
9import {AddAuthToToolbar, ClusterCtx, DataLoader, EmptyState, ObservableQuery, Page, Paginate, Query, Spinner} from '../../../shared/components';
10import {AuthSettingsCtx, Consumer, Context, ContextApis} from '../../../shared/context';
11import * as models from '../../../shared/models';
12import {AppsListViewKey, AppsListPreferences, AppsListViewType, HealthStatusBarPreferences, services} from '../../../shared/services';
13import {ApplicationCreatePanel} from '../application-create-panel/application-create-panel';
14import {ApplicationSyncPanel} from '../application-sync-panel/application-sync-panel';
15import {ApplicationsSyncPanel} from '../applications-sync-panel/applications-sync-panel';
16import * as AppUtils from '../utils';
17import {ApplicationsFilter, FilteredApp, getFilterResults} from './applications-filter';
18import {ApplicationsStatusBar} from './applications-status-bar';
19import {ApplicationsSummary} from './applications-summary';
20import {ApplicationsTable} from './applications-table';
21import {ApplicationTiles} from './applications-tiles';
22import {ApplicationsRefreshPanel} from '../applications-refresh-panel/applications-refresh-panel';
23import {useSidebarTarget} from '../../../sidebar/sidebar';
24
25import './applications-list.scss';
26import './flex-top-bar.scss';
27
28const EVENTS_BUFFER_TIMEOUT = 500;
29const WATCH_RETRY_TIMEOUT = 500;
30
31// The applications list/watch API supports only selected set of fields.
32// Make sure to register any new fields in the `appFields` map of `pkg/apiclient/application/forwarder_overwrite.go`.
33const APP_FIELDS = [
34'metadata.name',
35'metadata.namespace',
36'metadata.annotations',
37'metadata.labels',
38'metadata.creationTimestamp',
39'metadata.deletionTimestamp',
40'spec',
41'operation.sync',
42'status.sync.status',
43'status.sync.revision',
44'status.health',
45'status.operationState.phase',
46'status.operationState.finishedAt',
47'status.operationState.operation.sync',
48'status.summary',
49'status.resources'
50];
51const APP_LIST_FIELDS = ['metadata.resourceVersion', ...APP_FIELDS.map(field => `items.${field}`)];
52const APP_WATCH_FIELDS = ['result.type', ...APP_FIELDS.map(field => `result.application.${field}`)];
53
54function loadApplications(projects: string[], appNamespace: string): Observable<models.Application[]> {
55return from(services.applications.list(projects, {appNamespace, fields: APP_LIST_FIELDS})).pipe(
56mergeMap(applicationsList => {
57const applications = applicationsList.items;
58return merge(
59from([applications]),
60services.applications
61.watch({projects, resourceVersion: applicationsList.metadata.resourceVersion}, {fields: APP_WATCH_FIELDS})
62.pipe(repeat())
63.pipe(retryWhen(errors => errors.pipe(delay(WATCH_RETRY_TIMEOUT))))
64// batch events to avoid constant re-rendering and improve UI performance
65.pipe(bufferTime(EVENTS_BUFFER_TIMEOUT))
66.pipe(
67map(appChanges => {
68appChanges.forEach(appChange => {
69const index = applications.findIndex(item => AppUtils.appInstanceName(item) === AppUtils.appInstanceName(appChange.application));
70switch (appChange.type) {
71case 'DELETED':
72if (index > -1) {
73applications.splice(index, 1);
74}
75break;
76default:
77if (index > -1) {
78applications[index] = appChange.application;
79} else {
80applications.unshift(appChange.application);
81}
82break;
83}
84});
85return {applications, updated: appChanges.length > 0};
86})
87)
88.pipe(filter(item => item.updated))
89.pipe(map(item => item.applications))
90);
91})
92);
93}
94
95const ViewPref = ({children}: {children: (pref: AppsListPreferences & {page: number; search: string}) => React.ReactNode}) => (
96<ObservableQuery>
97{q => (
98<DataLoader
99load={() =>
100combineLatest([services.viewPreferences.getPreferences().pipe(map(item => item.appList)), q]).pipe(
101map(items => {
102const params = items[1];
103const viewPref: AppsListPreferences = {...items[0]};
104if (params.get('proj') != null) {
105viewPref.projectsFilter = params
106.get('proj')
107.split(',')
108.filter(item => !!item);
109}
110if (params.get('sync') != null) {
111viewPref.syncFilter = params
112.get('sync')
113.split(',')
114.filter(item => !!item);
115}
116if (params.get('autoSync') != null) {
117viewPref.autoSyncFilter = params
118.get('autoSync')
119.split(',')
120.filter(item => !!item);
121}
122if (params.get('health') != null) {
123viewPref.healthFilter = params
124.get('health')
125.split(',')
126.filter(item => !!item);
127}
128if (params.get('namespace') != null) {
129viewPref.namespacesFilter = params
130.get('namespace')
131.split(',')
132.filter(item => !!item);
133}
134if (params.get('cluster') != null) {
135viewPref.clustersFilter = params
136.get('cluster')
137.split(',')
138.filter(item => !!item);
139}
140if (params.get('showFavorites') != null) {
141viewPref.showFavorites = params.get('showFavorites') === 'true';
142}
143if (params.get('view') != null) {
144viewPref.view = params.get('view') as AppsListViewType;
145}
146if (params.get('labels') != null) {
147viewPref.labelsFilter = params
148.get('labels')
149.split(',')
150.map(decodeURIComponent)
151.filter(item => !!item);
152}
153return {...viewPref, page: parseInt(params.get('page') || '0', 10), search: params.get('search') || ''};
154})
155)
156}>
157{pref => children(pref)}
158</DataLoader>
159)}
160</ObservableQuery>
161);
162
163function filterApps(applications: models.Application[], pref: AppsListPreferences, search: string): {filteredApps: models.Application[]; filterResults: FilteredApp[]} {
164applications = applications.map(app => {
165let isAppOfAppsPattern = false;
166for (const resource of app.status.resources) {
167if (resource.kind === 'Application') {
168isAppOfAppsPattern = true;
169break;
170}
171}
172return {...app, isAppOfAppsPattern};
173});
174const filterResults = getFilterResults(applications, pref);
175return {
176filterResults,
177filteredApps: filterResults.filter(
178app => (search === '' || app.metadata.name.includes(search) || app.metadata.namespace.includes(search)) && Object.values(app.filterResult).every(val => val)
179)
180};
181}
182
183function tryJsonParse(input: string) {
184try {
185return (input && JSON.parse(input)) || null;
186} catch {
187return null;
188}
189}
190
191const SearchBar = (props: {content: string; ctx: ContextApis; apps: models.Application[]}) => {
192const {content, ctx, apps} = {...props};
193
194const searchBar = React.useRef<HTMLDivElement>(null);
195
196const query = new URLSearchParams(window.location.search);
197const appInput = tryJsonParse(query.get('new'));
198
199const {useKeybinding} = React.useContext(KeybindingContext);
200const [isFocused, setFocus] = React.useState(false);
201const useAuthSettingsCtx = React.useContext(AuthSettingsCtx);
202
203useKeybinding({
204keys: Key.SLASH,
205action: () => {
206if (searchBar.current && !appInput) {
207searchBar.current.querySelector('input').focus();
208setFocus(true);
209return true;
210}
211return false;
212}
213});
214
215useKeybinding({
216keys: Key.ESCAPE,
217action: () => {
218if (searchBar.current && !appInput && isFocused) {
219searchBar.current.querySelector('input').blur();
220setFocus(false);
221return true;
222}
223return false;
224}
225});
226
227return (
228<Autocomplete
229filterSuggestions={true}
230renderInput={inputProps => (
231<div className='applications-list__search' ref={searchBar}>
232<i
233className='fa fa-search'
234style={{marginRight: '9px', cursor: 'pointer'}}
235onClick={() => {
236if (searchBar.current) {
237searchBar.current.querySelector('input').focus();
238}
239}}
240/>
241<input
242{...inputProps}
243onFocus={e => {
244e.target.select();
245if (inputProps.onFocus) {
246inputProps.onFocus(e);
247}
248}}
249style={{fontSize: '14px'}}
250className='argo-field'
251placeholder='Search applications...'
252/>
253<div className='keyboard-hint'>/</div>
254{content && (
255<i className='fa fa-times' onClick={() => ctx.navigation.goto('.', {search: null}, {replace: true})} style={{cursor: 'pointer', marginLeft: '5px'}} />
256)}
257</div>
258)}
259wrapperProps={{className: 'applications-list__search-wrapper'}}
260renderItem={item => (
261<React.Fragment>
262<i className='icon argo-icon-application' /> {item.label}
263</React.Fragment>
264)}
265onSelect={val => {
266ctx.navigation.goto(`./${val}`);
267}}
268onChange={e => ctx.navigation.goto('.', {search: e.target.value}, {replace: true})}
269value={content || ''}
270items={apps.map(app => AppUtils.appQualifiedName(app, useAuthSettingsCtx?.appsInAnyNamespaceEnabled))}
271/>
272);
273};
274
275const FlexTopBar = (props: {toolbar: Toolbar | Observable<Toolbar>}) => {
276const ctx = React.useContext(Context);
277const loadToolbar = AddAuthToToolbar(props.toolbar, ctx);
278return (
279<React.Fragment>
280<div className='top-bar row flex-top-bar' key='tool-bar'>
281<DataLoader load={() => loadToolbar}>
282{toolbar => (
283<React.Fragment>
284<div className='flex-top-bar__actions'>
285{toolbar.actionMenu && (
286<React.Fragment>
287{toolbar.actionMenu.items.map((item, i) => (
288<button
289disabled={!!item.disabled}
290qe-id={item.qeId}
291className='argo-button argo-button--base'
292onClick={() => item.action()}
293style={{marginRight: 2}}
294key={i}>
295{item.iconClassName && <i className={item.iconClassName} style={{marginLeft: '-5px', marginRight: '5px'}} />}
296<span className='show-for-large'>{item.title}</span>
297</button>
298))}
299</React.Fragment>
300)}
301</div>
302<div className='flex-top-bar__tools'>{toolbar.tools}</div>
303</React.Fragment>
304)}
305</DataLoader>
306</div>
307<div className='flex-top-bar__padder' />
308</React.Fragment>
309);
310};
311
312export const ApplicationsList = (props: RouteComponentProps<{}>) => {
313const query = new URLSearchParams(props.location.search);
314const appInput = tryJsonParse(query.get('new'));
315const syncAppsInput = tryJsonParse(query.get('syncApps'));
316const refreshAppsInput = tryJsonParse(query.get('refreshApps'));
317const [createApi, setCreateApi] = React.useState(null);
318const clusters = React.useMemo(() => services.clusters.list(), []);
319const [isAppCreatePending, setAppCreatePending] = React.useState(false);
320const loaderRef = React.useRef<DataLoader>();
321const {List, Summary, Tiles} = AppsListViewKey;
322
323function refreshApp(appName: string, appNamespace: string) {
324// app refreshing might be done too quickly so that UI might miss it due to event batching
325// add refreshing annotation in the UI to improve user experience
326if (loaderRef.current) {
327const applications = loaderRef.current.getData() as models.Application[];
328const app = applications.find(item => item.metadata.name === appName && item.metadata.namespace === appNamespace);
329if (app) {
330AppUtils.setAppRefreshing(app);
331loaderRef.current.setData(applications);
332}
333}
334services.applications.get(appName, appNamespace, 'normal');
335}
336
337function onFilterPrefChanged(ctx: ContextApis, newPref: AppsListPreferences) {
338services.viewPreferences.updatePreferences({appList: newPref});
339ctx.navigation.goto(
340'.',
341{
342proj: newPref.projectsFilter.join(','),
343sync: newPref.syncFilter.join(','),
344autoSync: newPref.autoSyncFilter.join(','),
345health: newPref.healthFilter.join(','),
346namespace: newPref.namespacesFilter.join(','),
347cluster: newPref.clustersFilter.join(','),
348labels: newPref.labelsFilter.map(encodeURIComponent).join(',')
349},
350{replace: true}
351);
352}
353
354function getPageTitle(view: string) {
355switch (view) {
356case List:
357return 'Applications List';
358case Tiles:
359return 'Applications Tiles';
360case Summary:
361return 'Applications Summary';
362}
363return '';
364}
365
366const sidebarTarget = useSidebarTarget();
367
368return (
369<ClusterCtx.Provider value={clusters}>
370<KeybindingProvider>
371<Consumer>
372{ctx => (
373<ViewPref>
374{pref => (
375<Page
376key={pref.view}
377title={getPageTitle(pref.view)}
378useTitleOnly={true}
379toolbar={{breadcrumbs: [{title: 'Applications', path: '/applications'}]}}
380hideAuth={true}>
381<DataLoader
382input={pref.projectsFilter?.join(',')}
383ref={loaderRef}
384load={() => AppUtils.handlePageVisibility(() => loadApplications(pref.projectsFilter, query.get('appNamespace')))}
385loadingRenderer={() => (
386<div className='argo-container'>
387<MockupList height={100} marginTop={30} />
388</div>
389)}>
390{(applications: models.Application[]) => {
391const healthBarPrefs = pref.statusBarView || ({} as HealthStatusBarPreferences);
392const {filteredApps, filterResults} = filterApps(applications, pref, pref.search);
393return (
394<React.Fragment>
395<FlexTopBar
396toolbar={{
397tools: (
398<React.Fragment key='app-list-tools'>
399<Query>{q => <SearchBar content={q.get('search')} apps={applications} ctx={ctx} />}</Query>
400<Tooltip content='Toggle Health Status Bar'>
401<button
402className={`applications-list__accordion argo-button argo-button--base${
403healthBarPrefs.showHealthStatusBar ? '-o' : ''
404}`}
405style={{border: 'none'}}
406onClick={() => {
407healthBarPrefs.showHealthStatusBar = !healthBarPrefs.showHealthStatusBar;
408services.viewPreferences.updatePreferences({
409appList: {
410...pref,
411statusBarView: {
412...healthBarPrefs,
413showHealthStatusBar: healthBarPrefs.showHealthStatusBar
414}
415}
416});
417}}>
418<i className={`fas fa-ruler-horizontal`} />
419</button>
420</Tooltip>
421<div className='applications-list__view-type' style={{marginLeft: 'auto'}}>
422<i
423className={classNames('fa fa-th', {selected: pref.view === Tiles}, 'menu_icon')}
424title='Tiles'
425onClick={() => {
426ctx.navigation.goto('.', {view: Tiles});
427services.viewPreferences.updatePreferences({appList: {...pref, view: Tiles}});
428}}
429/>
430<i
431className={classNames('fa fa-th-list', {selected: pref.view === List}, 'menu_icon')}
432title='List'
433onClick={() => {
434ctx.navigation.goto('.', {view: List});
435services.viewPreferences.updatePreferences({appList: {...pref, view: List}});
436}}
437/>
438<i
439className={classNames('fa fa-chart-pie', {selected: pref.view === Summary}, 'menu_icon')}
440title='Summary'
441onClick={() => {
442ctx.navigation.goto('.', {view: Summary});
443services.viewPreferences.updatePreferences({appList: {...pref, view: Summary}});
444}}
445/>
446</div>
447</React.Fragment>
448),
449actionMenu: {
450items: [
451{
452title: 'New App',
453iconClassName: 'fa fa-plus',
454qeId: 'applications-list-button-new-app',
455action: () => ctx.navigation.goto('.', {new: '{}'}, {replace: true})
456},
457{
458title: 'Sync Apps',
459iconClassName: 'fa fa-sync',
460action: () => ctx.navigation.goto('.', {syncApps: true}, {replace: true})
461},
462{
463title: 'Refresh Apps',
464iconClassName: 'fa fa-redo',
465action: () => ctx.navigation.goto('.', {refreshApps: true}, {replace: true})
466}
467]
468}
469}}
470/>
471<div className='applications-list'>
472{applications.length === 0 && pref.projectsFilter?.length === 0 && (pref.labelsFilter || []).length === 0 ? (
473<EmptyState icon='argo-icon-application'>
474<h4>No applications available to you just yet</h4>
475<h5>Create new application to start managing resources in your cluster</h5>
476<button
477qe-id='applications-list-button-create-application'
478className='argo-button argo-button--base'
479onClick={() => ctx.navigation.goto('.', {new: JSON.stringify({})}, {replace: true})}>
480Create application
481</button>
482</EmptyState>
483) : (
484<>
485{ReactDOM.createPortal(
486<DataLoader load={() => services.viewPreferences.getPreferences()}>
487{allpref => (
488<ApplicationsFilter
489apps={filterResults}
490onChange={newPrefs => onFilterPrefChanged(ctx, newPrefs)}
491pref={pref}
492collapsed={allpref.hideSidebar}
493/>
494)}
495</DataLoader>,
496sidebarTarget?.current
497)}
498
499{(pref.view === 'summary' && <ApplicationsSummary applications={filteredApps} />) || (
500<Paginate
501header={filteredApps.length > 1 && <ApplicationsStatusBar applications={filteredApps} />}
502showHeader={healthBarPrefs.showHealthStatusBar}
503preferencesKey='applications-list'
504page={pref.page}
505emptyState={() => (
506<EmptyState icon='fa fa-search'>
507<h4>No matching applications found</h4>
508<h5>
509Change filter criteria or
510<a
511onClick={() => {
512AppsListPreferences.clearFilters(pref);
513onFilterPrefChanged(ctx, pref);
514}}>
515clear filters
516</a>
517</h5>
518</EmptyState>
519)}
520sortOptions={[
521{title: 'Name', compare: (a, b) => a.metadata.name.localeCompare(b.metadata.name)},
522{
523title: 'Created At',
524compare: (b, a) => a.metadata.creationTimestamp.localeCompare(b.metadata.creationTimestamp)
525},
526{
527title: 'Synchronized',
528compare: (b, a) =>
529a.status.operationState?.finishedAt?.localeCompare(b.status.operationState?.finishedAt)
530}
531]}
532data={filteredApps}
533onPageChange={page => ctx.navigation.goto('.', {page})}>
534{data =>
535(pref.view === 'tiles' && (
536<ApplicationTiles
537applications={data}
538syncApplication={(appName, appNamespace) =>
539ctx.navigation.goto('.', {syncApp: appName, appNamespace}, {replace: true})
540}
541refreshApplication={refreshApp}
542deleteApplication={(appName, appNamespace) =>
543AppUtils.deleteApplication(appName, appNamespace, ctx)
544}
545/>
546)) || (
547<ApplicationsTable
548applications={data}
549syncApplication={(appName, appNamespace) =>
550ctx.navigation.goto('.', {syncApp: appName, appNamespace}, {replace: true})
551}
552refreshApplication={refreshApp}
553deleteApplication={(appName, appNamespace) =>
554AppUtils.deleteApplication(appName, appNamespace, ctx)
555}
556/>
557)
558}
559</Paginate>
560)}
561</>
562)}
563<ApplicationsSyncPanel
564key='syncsPanel'
565show={syncAppsInput}
566hide={() => ctx.navigation.goto('.', {syncApps: null}, {replace: true})}
567apps={filteredApps}
568/>
569<ApplicationsRefreshPanel
570key='refreshPanel'
571show={refreshAppsInput}
572hide={() => ctx.navigation.goto('.', {refreshApps: null}, {replace: true})}
573apps={filteredApps}
574/>
575</div>
576<ObservableQuery>
577{q => (
578<DataLoader
579load={() =>
580q.pipe(
581mergeMap(params => {
582const syncApp = params.get('syncApp');
583const appNamespace = params.get('appNamespace');
584return (syncApp && from(services.applications.get(syncApp, appNamespace))) || from([null]);
585})
586)
587}>
588{app => (
589<ApplicationSyncPanel
590key='syncPanel'
591application={app}
592selectedResource={'all'}
593hide={() => ctx.navigation.goto('.', {syncApp: null}, {replace: true})}
594/>
595)}
596</DataLoader>
597)}
598</ObservableQuery>
599<SlidingPanel
600isShown={!!appInput}
601onClose={() => ctx.navigation.goto('.', {new: null}, {replace: true})}
602header={
603<div>
604<button
605qe-id='applications-list-button-create'
606className='argo-button argo-button--base'
607disabled={isAppCreatePending}
608onClick={() => createApi && createApi.submitForm(null)}>
609<Spinner show={isAppCreatePending} style={{marginRight: '5px'}} />
610Create
611</button>{' '}
612<button
613qe-id='applications-list-button-cancel'
614onClick={() => ctx.navigation.goto('.', {new: null}, {replace: true})}
615className='argo-button argo-button--base-o'>
616Cancel
617</button>
618</div>
619}>
620{appInput && (
621<ApplicationCreatePanel
622getFormApi={api => {
623setCreateApi(api);
624}}
625createApp={async app => {
626setAppCreatePending(true);
627try {
628await services.applications.create(app);
629ctx.navigation.goto('.', {new: null}, {replace: true});
630} catch (e) {
631ctx.notifications.show({
632content: <ErrorNotification title='Unable to create application' e={e} />,
633type: NotificationType.Error
634});
635} finally {
636setAppCreatePending(false);
637}
638}}
639app={appInput}
640onAppChanged={app => ctx.navigation.goto('.', {new: JSON.stringify(app)}, {replace: true})}
641/>
642)}
643</SlidingPanel>
644</React.Fragment>
645);
646}}
647</DataLoader>
648</Page>
649)}
650</ViewPref>
651)}
652</Consumer>
653</KeybindingProvider>
654</ClusterCtx.Provider>
655);
656};
657