argo-cd
616 строк · 30.8 Кб
1import {AutocompleteField, DropDownMenu, ErrorNotification, FormField, FormSelect, HelpIcon, NotificationType} from 'argo-ui';
2import * as React from 'react';
3import {FormApi, Text} from 'react-form';
4import {
5ClipboardText,
6Cluster,
7DataLoader,
8EditablePanel,
9EditablePanelItem,
10Expandable,
11MapInputField,
12NumberField,
13Repo,
14Revision,
15RevisionHelpIcon
16} from '../../../shared/components';
17import {BadgePanel, Spinner} from '../../../shared/components';
18import {AuthSettingsCtx, Consumer, ContextApis} from '../../../shared/context';
19import * as models from '../../../shared/models';
20import {services} from '../../../shared/services';
21
22import {ApplicationSyncOptionsField} from '../application-sync-options/application-sync-options';
23import {RevisionFormField} from '../revision-form-field/revision-form-field';
24import {ComparisonStatusIcon, HealthStatusIcon, syncStatusMessage, urlPattern, formatCreationTimestamp, getAppDefaultSource, getAppSpecDefaultSource, helpTip} from '../utils';
25import {ApplicationRetryOptions} from '../application-retry-options/application-retry-options';
26import {ApplicationRetryView} from '../application-retry-view/application-retry-view';
27import {Link} from 'react-router-dom';
28import {EditNotificationSubscriptions, useEditNotificationSubscriptions} from './edit-notification-subscriptions';
29import {EditAnnotations} from './edit-annotations';
30
31import './application-summary.scss';
32import {DeepLinks} from '../../../shared/components/deep-links';
33
34function swap(array: any[], a: number, b: number) {
35array = array.slice();
36[array[a], array[b]] = [array[b], array[a]];
37return array;
38}
39
40function processPath(path: string) {
41if (path !== null && path !== undefined) {
42if (path === '.') {
43return '(root)';
44}
45return path;
46}
47return '';
48}
49
50export interface ApplicationSummaryProps {
51app: models.Application;
52updateApp: (app: models.Application, query: {validate?: boolean}) => Promise<any>;
53}
54
55export const ApplicationSummary = (props: ApplicationSummaryProps) => {
56const app = JSON.parse(JSON.stringify(props.app)) as models.Application;
57const source = getAppDefaultSource(app);
58const isHelm = source.hasOwnProperty('chart');
59const initialState = app.spec.destination.server === undefined ? 'NAME' : 'URL';
60const useAuthSettingsCtx = React.useContext(AuthSettingsCtx);
61const [destFormat, setDestFormat] = React.useState(initialState);
62const [changeSync, setChangeSync] = React.useState(false);
63
64const notificationSubscriptions = useEditNotificationSubscriptions(app.metadata.annotations || {});
65const updateApp = notificationSubscriptions.withNotificationSubscriptions(props.updateApp);
66
67const hasMultipleSources = app.spec.sources && app.spec.sources.length > 0;
68
69const attributes = [
70{
71title: 'PROJECT',
72view: <Link to={'/settings/projects/' + app.spec.project}>{app.spec.project}</Link>,
73edit: (formApi: FormApi) => (
74<DataLoader load={() => services.projects.list('items.metadata.name').then(projs => projs.map(item => item.metadata.name))}>
75{projects => <FormField formApi={formApi} field='spec.project' component={FormSelect} componentProps={{options: projects}} />}
76</DataLoader>
77)
78},
79{
80title: 'LABELS',
81view: Object.keys(app.metadata.labels || {})
82.map(label => `${label}=${app.metadata.labels[label]}`)
83.join(' '),
84edit: (formApi: FormApi) => <FormField formApi={formApi} field='metadata.labels' component={MapInputField} />
85},
86{
87title: 'ANNOTATIONS',
88view: (
89<Expandable height={48}>
90{Object.keys(app.metadata.annotations || {})
91.map(annotation => `${annotation}=${app.metadata.annotations[annotation]}`)
92.join(' ')}
93</Expandable>
94),
95edit: (formApi: FormApi) => <EditAnnotations formApi={formApi} app={app} />
96},
97{
98title: 'NOTIFICATION SUBSCRIPTIONS',
99view: false, // eventually the subscription input values will be merged in 'ANNOTATIONS', therefore 'ANNOATIONS' section is responsible to represent subscription values,
100edit: () => <EditNotificationSubscriptions {...notificationSubscriptions} />
101},
102{
103title: 'CLUSTER',
104view: <Cluster server={app.spec.destination.server} name={app.spec.destination.name} showUrl={true} />,
105edit: (formApi: FormApi) => (
106<DataLoader load={() => services.clusters.list().then(clusters => clusters.sort())}>
107{clusters => {
108return (
109<div className='row'>
110{(destFormat.toUpperCase() === 'URL' && (
111<div className='columns small-10'>
112<FormField
113formApi={formApi}
114field='spec.destination.server'
115componentProps={{items: clusters.map(cluster => cluster.server)}}
116component={AutocompleteField}
117/>
118</div>
119)) || (
120<div className='columns small-10'>
121<FormField
122formApi={formApi}
123field='spec.destination.name'
124componentProps={{items: clusters.map(cluster => cluster.name)}}
125component={AutocompleteField}
126/>
127</div>
128)}
129<div className='columns small-2'>
130<div>
131<DropDownMenu
132anchor={() => (
133<p>
134{destFormat.toUpperCase()} <i className='fa fa-caret-down' />
135</p>
136)}
137items={['URL', 'NAME'].map((type: 'URL' | 'NAME') => ({
138title: type,
139action: () => {
140if (destFormat !== type) {
141const updatedApp = formApi.getFormState().values as models.Application;
142if (type === 'URL') {
143updatedApp.spec.destination.server = '';
144delete updatedApp.spec.destination.name;
145} else {
146updatedApp.spec.destination.name = '';
147delete updatedApp.spec.destination.server;
148}
149formApi.setAllValues(updatedApp);
150setDestFormat(type);
151}
152}
153}))}
154/>
155</div>
156</div>
157</div>
158);
159}}
160</DataLoader>
161)
162},
163{
164title: 'NAMESPACE',
165view: <ClipboardText text={app.spec.destination.namespace} />,
166edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.destination.namespace' component={Text} />
167},
168{
169title: 'CREATED AT',
170view: formatCreationTimestamp(app.metadata.creationTimestamp)
171},
172{
173title: 'REPO URL',
174view: <Repo url={source.repoURL} />,
175edit: (formApi: FormApi) =>
176hasMultipleSources ? (
177helpTip('REPO URL is not editable for applications with multiple sources. You can edit them in the "Manifest" tab.')
178) : (
179<FormField formApi={formApi} field='spec.source.repoURL' component={Text} />
180)
181},
182...(isHelm
183? [
184{
185title: 'CHART',
186view: (
187<span>
188{source.chart}:{source.targetRevision}
189</span>
190),
191edit: (formApi: FormApi) =>
192hasMultipleSources ? (
193helpTip('CHART is not editable for applications with multiple sources. You can edit them in the "Manifest" tab.')
194) : (
195<DataLoader
196input={{repoURL: getAppSpecDefaultSource(formApi.getFormState().values.spec).repoURL}}
197load={src => services.repos.charts(src.repoURL).catch(() => new Array<models.HelmChart>())}>
198{(charts: models.HelmChart[]) => (
199<div className='row'>
200<div className='columns small-8'>
201<FormField
202formApi={formApi}
203field='spec.source.chart'
204component={AutocompleteField}
205componentProps={{
206items: charts.map(chart => chart.name),
207filterSuggestions: true
208}}
209/>
210</div>
211<DataLoader
212input={{charts, chart: getAppSpecDefaultSource(formApi.getFormState().values.spec).chart}}
213load={async data => {
214const chartInfo = data.charts.find(chart => chart.name === data.chart);
215return (chartInfo && chartInfo.versions) || new Array<string>();
216}}>
217{(versions: string[]) => (
218<div className='columns small-4'>
219<FormField
220formApi={formApi}
221field='spec.source.targetRevision'
222component={AutocompleteField}
223componentProps={{
224items: versions
225}}
226/>
227<RevisionHelpIcon type='helm' top='0' />
228</div>
229)}
230</DataLoader>
231</div>
232)}
233</DataLoader>
234)
235}
236]
237: [
238{
239title: 'TARGET REVISION',
240view: <Revision repoUrl={source.repoURL} revision={source.targetRevision || 'HEAD'} />,
241edit: (formApi: FormApi) =>
242hasMultipleSources ? (
243helpTip('TARGET REVISION is not editable for applications with multiple sources. You can edit them in the "Manifest" tab.')
244) : (
245<RevisionFormField helpIconTop={'0'} hideLabel={true} formApi={formApi} repoURL={source.repoURL} />
246)
247},
248{
249title: 'PATH',
250view: (
251<Revision repoUrl={source.repoURL} revision={source.targetRevision || 'HEAD'} path={source.path} isForPath={true}>
252{processPath(source.path)}
253</Revision>
254),
255edit: (formApi: FormApi) =>
256hasMultipleSources ? (
257helpTip('PATH is not editable for applications with multiple sources. You can edit them in the "Manifest" tab.')
258) : (
259<FormField formApi={formApi} field='spec.source.path' component={Text} />
260)
261}
262]),
263
264{
265title: 'REVISION HISTORY LIMIT',
266view: app.spec.revisionHistoryLimit,
267edit: (formApi: FormApi) => (
268<div style={{position: 'relative'}}>
269<FormField formApi={formApi} field='spec.revisionHistoryLimit' componentProps={{style: {paddingRight: '1em'}, placeholder: '10'}} component={NumberField} />
270<div style={{position: 'absolute', right: '0', top: '0'}}>
271<HelpIcon
272title='This limits the number of items kept in the apps revision history.
273This should only be changed in exceptional circumstances.
274Setting to zero will store no history. This will reduce storage used.
275Increasing will increase the space used to store the history, so we do not recommend increasing it.
276Default is 10.'
277/>
278</div>
279</div>
280)
281},
282{
283title: 'SYNC OPTIONS',
284view: (
285<div style={{display: 'flex', flexWrap: 'wrap'}}>
286{((app.spec.syncPolicy || {}).syncOptions || []).map(opt =>
287opt.endsWith('=true') || opt.endsWith('=false') ? (
288<div key={opt} style={{marginRight: '10px'}}>
289<i className={`fa fa-${opt.includes('=true') ? 'check-square' : 'times'}`} /> {opt.replace('=true', '').replace('=false', '')}
290</div>
291) : (
292<div key={opt} style={{marginRight: '10px'}}>
293{opt}
294</div>
295)
296)}
297</div>
298),
299edit: (formApi: FormApi) => (
300<div>
301<FormField formApi={formApi} field='spec.syncPolicy.syncOptions' component={ApplicationSyncOptionsField} />
302</div>
303)
304},
305{
306title: 'RETRY OPTIONS',
307view: <ApplicationRetryView initValues={app.spec.syncPolicy ? app.spec.syncPolicy.retry : null} />,
308edit: (formApi: FormApi) => (
309<div>
310<ApplicationRetryOptions formApi={formApi} initValues={app.spec.syncPolicy ? app.spec.syncPolicy.retry : null} field='spec.syncPolicy.retry' />
311</div>
312)
313},
314{
315title: 'STATUS',
316view: (
317<span>
318<ComparisonStatusIcon status={app.status.sync.status} /> {app.status.sync.status} {syncStatusMessage(app)}
319</span>
320)
321},
322{
323title: 'HEALTH',
324view: (
325<span>
326<HealthStatusIcon state={app.status.health} /> {app.status.health.status}
327</span>
328)
329},
330{
331title: 'LINKS',
332view: (
333<DataLoader load={() => services.applications.getLinks(app.metadata.name, app.metadata.namespace)} input={app} key='appLinks'>
334{(links: models.LinksResponse) => <DeepLinks links={links.items} />}
335</DataLoader>
336)
337}
338];
339
340const urls = app.status.summary.externalURLs || [];
341if (urls.length > 0) {
342attributes.push({
343title: 'URLs',
344view: (
345<React.Fragment>
346{urls
347.map(item => item.split('|'))
348.map((parts, i) => (
349<a key={i} href={parts.length > 1 ? parts[1] : parts[0]} target='__blank'>
350{parts[0]}
351</a>
352))}
353</React.Fragment>
354)
355});
356}
357
358if ((app.status.summary.images || []).length) {
359attributes.push({
360title: 'IMAGES',
361view: (
362<div className='application-summary__labels'>
363{(app.status.summary.images || []).sort().map(image => (
364<span className='application-summary__label' key={image}>
365{image}
366</span>
367))}
368</div>
369)
370});
371}
372
373async function setAutoSync(ctx: ContextApis, confirmationTitle: string, confirmationText: string, prune: boolean, selfHeal: boolean) {
374const confirmed = await ctx.popup.confirm(confirmationTitle, confirmationText);
375if (confirmed) {
376try {
377setChangeSync(true);
378const updatedApp = JSON.parse(JSON.stringify(props.app)) as models.Application;
379if (!updatedApp.spec.syncPolicy) {
380updatedApp.spec.syncPolicy = {};
381}
382updatedApp.spec.syncPolicy.automated = {prune, selfHeal};
383await updateApp(updatedApp, {validate: false});
384} catch (e) {
385ctx.notifications.show({
386content: <ErrorNotification title={`Unable to "${confirmationTitle.replace(/\?/g, '')}:`} e={e} />,
387type: NotificationType.Error
388});
389} finally {
390setChangeSync(false);
391}
392}
393}
394
395async function unsetAutoSync(ctx: ContextApis) {
396const confirmed = await ctx.popup.confirm('Disable Auto-Sync?', 'Are you sure you want to disable automated application synchronization');
397if (confirmed) {
398try {
399setChangeSync(true);
400const updatedApp = JSON.parse(JSON.stringify(props.app)) as models.Application;
401updatedApp.spec.syncPolicy.automated = null;
402await updateApp(updatedApp, {validate: false});
403} catch (e) {
404ctx.notifications.show({
405content: <ErrorNotification title='Unable to disable Auto-Sync' e={e} />,
406type: NotificationType.Error
407});
408} finally {
409setChangeSync(false);
410}
411}
412}
413
414const items = app.spec.info || [];
415const [adjustedCount, setAdjustedCount] = React.useState(0);
416
417const added = new Array<{name: string; value: string; key: string}>();
418for (let i = 0; i < adjustedCount; i++) {
419added.push({name: '', value: '', key: (items.length + i).toString()});
420}
421for (let i = 0; i > adjustedCount; i--) {
422items.pop();
423}
424const allItems = items.concat(added);
425const infoItems: EditablePanelItem[] = allItems
426.map((info, i) => ({
427key: i.toString(),
428title: info.name,
429view: info.value.match(urlPattern) ? (
430<a href={info.value} target='__blank'>
431{info.value}
432</a>
433) : (
434info.value
435),
436titleEdit: (formApi: FormApi) => (
437<React.Fragment>
438{i > 0 && (
439<i
440className='fa fa-sort-up application-summary__sort-icon'
441onClick={() => {
442formApi.setValue('spec.info', swap(formApi.getFormState().values.spec.info || [], i, i - 1));
443}}
444/>
445)}
446<FormField formApi={formApi} field={`spec.info[${[i]}].name`} component={Text} componentProps={{style: {width: '99%'}}} />
447{i < allItems.length - 1 && (
448<i
449className='fa fa-sort-down application-summary__sort-icon'
450onClick={() => {
451formApi.setValue('spec.info', swap(formApi.getFormState().values.spec.info || [], i, i + 1));
452}}
453/>
454)}
455</React.Fragment>
456),
457edit: (formApi: FormApi) => (
458<React.Fragment>
459<FormField formApi={formApi} field={`spec.info[${[i]}].value`} component={Text} />
460<i
461className='fa fa-times application-summary__remove-icon'
462onClick={() => {
463const values = (formApi.getFormState().values.spec.info || []) as Array<any>;
464formApi.setValue('spec.info', [...values.slice(0, i), ...values.slice(i + 1, values.length)]);
465setAdjustedCount(adjustedCount - 1);
466}}
467/>
468</React.Fragment>
469)
470}))
471.concat({
472key: '-1',
473title: '',
474titleEdit: () => (
475<button
476className='argo-button argo-button--base'
477onClick={() => {
478setAdjustedCount(adjustedCount + 1);
479}}>
480ADD NEW ITEM
481</button>
482),
483view: null as any,
484edit: null
485});
486
487return (
488<div className='application-summary'>
489<EditablePanel
490save={updateApp}
491validate={input => ({
492'spec.project': !input.spec.project && 'Project name is required',
493'spec.destination.server': !input.spec.destination.server && input.spec.destination.hasOwnProperty('server') && 'Cluster server is required',
494'spec.destination.name': !input.spec.destination.name && input.spec.destination.hasOwnProperty('name') && 'Cluster name is required'
495})}
496values={app}
497title={app.metadata.name.toLocaleUpperCase()}
498items={attributes}
499onModeSwitch={() => notificationSubscriptions.onResetNotificationSubscriptions()}
500/>
501<Consumer>
502{ctx => (
503<div className='white-box'>
504<div className='white-box__details'>
505<p>SYNC POLICY</p>
506<div className='row white-box__details-row'>
507<div className='columns small-3'>{(app.spec.syncPolicy && app.spec.syncPolicy.automated && <span>AUTOMATED</span>) || <span>NONE</span>}</div>
508<div className='columns small-9'>
509{(app.spec.syncPolicy && app.spec.syncPolicy.automated && (
510<button className='argo-button argo-button--base' onClick={() => unsetAutoSync(ctx)}>
511<Spinner show={changeSync} style={{marginRight: '5px'}} />
512Disable Auto-Sync
513</button>
514)) || (
515<button
516className='argo-button argo-button--base'
517onClick={() =>
518setAutoSync(ctx, 'Enable Auto-Sync?', 'Are you sure you want to enable automated application synchronization?', false, false)
519}>
520<Spinner show={changeSync} style={{marginRight: '5px'}} />
521Enable Auto-Sync
522</button>
523)}
524</div>
525</div>
526
527{app.spec.syncPolicy && app.spec.syncPolicy.automated && (
528<React.Fragment>
529<div className='row white-box__details-row'>
530<div className='columns small-3'>PRUNE RESOURCES</div>
531<div className='columns small-9'>
532{(app.spec.syncPolicy.automated.prune && (
533<button
534className='argo-button argo-button--base'
535onClick={() =>
536setAutoSync(
537ctx,
538'Disable Prune Resources?',
539'Are you sure you want to disable resource pruning during automated application synchronization?',
540false,
541app.spec.syncPolicy.automated.selfHeal
542)
543}>
544Disable
545</button>
546)) || (
547<button
548className='argo-button argo-button--base'
549onClick={() =>
550setAutoSync(
551ctx,
552'Enable Prune Resources?',
553'Are you sure you want to enable resource pruning during automated application synchronization?',
554true,
555app.spec.syncPolicy.automated.selfHeal
556)
557}>
558Enable
559</button>
560)}
561</div>
562</div>
563<div className='row white-box__details-row'>
564<div className='columns small-3'>SELF HEAL</div>
565<div className='columns small-9'>
566{(app.spec.syncPolicy.automated.selfHeal && (
567<button
568className='argo-button argo-button--base'
569onClick={() =>
570setAutoSync(
571ctx,
572'Disable Self Heal?',
573'Are you sure you want to disable automated self healing?',
574app.spec.syncPolicy.automated.prune,
575false
576)
577}>
578Disable
579</button>
580)) || (
581<button
582className='argo-button argo-button--base'
583onClick={() =>
584setAutoSync(
585ctx,
586'Enable Self Heal?',
587'Are you sure you want to enable automated self healing?',
588app.spec.syncPolicy.automated.prune,
589true
590)
591}>
592Enable
593</button>
594)}
595</div>
596</div>
597</React.Fragment>
598)}
599</div>
600</div>
601)}
602</Consumer>
603<BadgePanel app={props.app.metadata.name} appNamespace={props.app.metadata.namespace} nsEnabled={useAuthSettingsCtx?.appsInAnyNamespaceEnabled} />
604<EditablePanel
605save={updateApp}
606values={app}
607title='INFO'
608items={infoItems}
609onModeSwitch={() => {
610setAdjustedCount(0);
611notificationSubscriptions.onResetNotificationSubscriptions();
612}}
613/>
614</div>
615);
616};
617