argo-cd
567 строк · 26.0 Кб
1import {AutocompleteField, DataLoader, FormField, FormSelect, getNestedField} from 'argo-ui';
2import * as React from 'react';
3import {FieldApi, FormApi, FormField as ReactFormField, Text, TextArea} from 'react-form';
4import {cloneDeep} from 'lodash-es';
5import {
6ArrayInputField,
7ArrayValueField,
8CheckboxField,
9EditablePanel,
10EditablePanelItem,
11Expandable,
12MapValueField,
13NameValueEditor,
14StringValueField,
15NameValue,
16TagsInputField,
17ValueEditor
18} from '../../../shared/components';
19import * as models from '../../../shared/models';
20import {ApplicationSourceDirectory, Plugin} from '../../../shared/models';
21import {services} from '../../../shared/services';
22import {ImageTagFieldEditor} from './kustomize';
23import * as kustomize from './kustomize-image';
24import {VarsInputField} from './vars-input-field';
25import {concatMaps} from '../../../shared/utils';
26import {getAppDefaultSource} from '../utils';
27import * as jsYaml from 'js-yaml';
28
29const TextWithMetadataField = ReactFormField((props: {metadata: {value: string}; fieldApi: FieldApi; className: string}) => {
30const {
31fieldApi: {getValue, setValue}
32} = props;
33const metadata = getValue() || props.metadata;
34
35return <input className={props.className} value={metadata.value} onChange={el => setValue({...metadata, value: el.target.value})} />;
36});
37
38function distinct<T>(first: IterableIterator<T>, second: IterableIterator<T>) {
39return Array.from(new Set(Array.from(first).concat(Array.from(second))));
40}
41
42function overridesFirst(first: {overrideIndex: number; metadata: {name: string}}, second: {overrideIndex: number; metadata: {name: string}}) {
43if (first.overrideIndex === second.overrideIndex) {
44return first.metadata.name.localeCompare(second.metadata.name);
45}
46if (first.overrideIndex < 0) {
47return 1;
48} else if (second.overrideIndex < 0) {
49return -1;
50}
51return first.overrideIndex - second.overrideIndex;
52}
53
54function getParamsEditableItems(
55app: models.Application,
56title: string,
57fieldsPath: string,
58removedOverrides: boolean[],
59setRemovedOverrides: React.Dispatch<boolean[]>,
60params: {
61key?: string;
62overrideIndex: number;
63original: string;
64metadata: {name: string; value: string};
65}[],
66component: React.ComponentType = TextWithMetadataField
67) {
68return params
69.sort(overridesFirst)
70.map((param, i) => ({
71key: param.key,
72title: param.metadata.name,
73view: (
74<span title={param.metadata.value}>
75{param.overrideIndex > -1 && <span className='fa fa-gavel' title={`Original value: ${param.original}`} />} {param.metadata.value}
76</span>
77),
78edit: (formApi: FormApi) => {
79const labelStyle = {position: 'absolute', right: 0, top: 0, zIndex: 11} as any;
80const overrideRemoved = removedOverrides[i];
81const fieldItemPath = `${fieldsPath}[${i}]`;
82return (
83<React.Fragment>
84{(overrideRemoved && <span>{param.original}</span>) || (
85<FormField
86formApi={formApi}
87field={fieldItemPath}
88component={component}
89componentProps={{
90metadata: param.metadata
91}}
92/>
93)}
94{param.metadata.value !== param.original && !overrideRemoved && (
95<a
96onClick={() => {
97formApi.setValue(fieldItemPath, null);
98removedOverrides[i] = true;
99setRemovedOverrides(removedOverrides);
100}}
101style={labelStyle}>
102Remove override
103</a>
104)}
105{overrideRemoved && (
106<a
107onClick={() => {
108formApi.setValue(fieldItemPath, getNestedField(app, fieldsPath)[i]);
109removedOverrides[i] = false;
110setRemovedOverrides(removedOverrides);
111}}
112style={labelStyle}>
113Keep override
114</a>
115)}
116</React.Fragment>
117);
118}
119}))
120.map((item, i) => ({...item, before: (i === 0 && <p style={{marginTop: '1em'}}>{title}</p>) || null}));
121}
122
123export const ApplicationParameters = (props: {
124application: models.Application;
125details: models.RepoAppDetails;
126save?: (application: models.Application, query: {validate?: boolean}) => Promise<any>;
127noReadonlyMode?: boolean;
128}) => {
129const app = cloneDeep(props.application);
130const source = getAppDefaultSource(app);
131const [removedOverrides, setRemovedOverrides] = React.useState(new Array<boolean>());
132
133let attributes: EditablePanelItem[] = [];
134const isValuesObject = source?.helm?.valuesObject;
135const helmValues = isValuesObject ? jsYaml.safeDump(source.helm.valuesObject) : source?.helm?.values;
136const [appParamsDeletedState, setAppParamsDeletedState] = React.useState([]);
137
138if (props.details.type === 'Kustomize' && props.details.kustomize) {
139attributes.push({
140title: 'VERSION',
141view: (source.kustomize && source.kustomize.version) || <span>default</span>,
142edit: (formApi: FormApi) => (
143<DataLoader load={() => services.authService.settings()}>
144{settings =>
145((settings.kustomizeVersions || []).length > 0 && (
146<FormField formApi={formApi} field='spec.source.kustomize.version' component={AutocompleteField} componentProps={{items: settings.kustomizeVersions}} />
147)) || <span>default</span>
148}
149</DataLoader>
150)
151});
152
153attributes.push({
154title: 'NAME PREFIX',
155view: source.kustomize && source.kustomize.namePrefix,
156edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.source.kustomize.namePrefix' component={Text} />
157});
158
159attributes.push({
160title: 'NAME SUFFIX',
161view: source.kustomize && source.kustomize.nameSuffix,
162edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.source.kustomize.nameSuffix' component={Text} />
163});
164
165attributes.push({
166title: 'NAMESPACE',
167view: app.spec.source.kustomize && app.spec.source.kustomize.namespace,
168edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.source.kustomize.namespace' component={Text} />
169});
170
171const srcImages = ((props.details && props.details.kustomize && props.details.kustomize.images) || []).map(val => kustomize.parse(val));
172const images = ((source.kustomize && source.kustomize.images) || []).map(val => kustomize.parse(val));
173
174if (srcImages.length > 0) {
175const imagesByName = new Map<string, kustomize.Image>();
176srcImages.forEach(img => imagesByName.set(img.name, img));
177
178const overridesByName = new Map<string, number>();
179images.forEach((override, i) => overridesByName.set(override.name, i));
180
181attributes = attributes.concat(
182getParamsEditableItems(
183app,
184'IMAGES',
185'spec.source.kustomize.images',
186removedOverrides,
187setRemovedOverrides,
188distinct(imagesByName.keys(), overridesByName.keys()).map(name => {
189const param = imagesByName.get(name);
190const original = param && kustomize.format(param);
191let overrideIndex = overridesByName.get(name);
192if (overrideIndex === undefined) {
193overrideIndex = -1;
194}
195const value = (overrideIndex > -1 && kustomize.format(images[overrideIndex])) || original;
196return {overrideIndex, original, metadata: {name, value}};
197}),
198ImageTagFieldEditor
199)
200);
201}
202} else if (props.details.type === 'Helm' && props.details.helm) {
203attributes.push({
204title: 'VALUES FILES',
205view: (source.helm && (source.helm.valueFiles || []).join(', ')) || 'No values files selected',
206edit: (formApi: FormApi) => (
207<FormField
208formApi={formApi}
209field='spec.source.helm.valueFiles'
210component={TagsInputField}
211componentProps={{
212options: props.details.helm.valueFiles,
213noTagsLabel: 'No values files selected'
214}}
215/>
216)
217});
218attributes.push({
219title: 'VALUES',
220view: source.helm && (
221<Expandable>
222<pre>{helmValues}</pre>
223</Expandable>
224),
225edit: (formApi: FormApi) => {
226// In case source.helm.valuesObject is set, set source.helm.values to its value
227if (source.helm) {
228source.helm.values = helmValues;
229}
230
231return (
232<div>
233<pre>
234<FormField formApi={formApi} field='spec.source.helm.values' component={TextArea} />
235</pre>
236</div>
237);
238}
239});
240const paramsByName = new Map<string, models.HelmParameter>();
241(props.details.helm.parameters || []).forEach(param => paramsByName.set(param.name, param));
242const overridesByName = new Map<string, number>();
243((source.helm && source.helm.parameters) || []).forEach((override, i) => overridesByName.set(override.name, i));
244attributes = attributes.concat(
245getParamsEditableItems(
246app,
247'PARAMETERS',
248'spec.source.helm.parameters',
249removedOverrides,
250setRemovedOverrides,
251distinct(paramsByName.keys(), overridesByName.keys()).map(name => {
252const param = paramsByName.get(name);
253const original = (param && param.value) || '';
254let overrideIndex = overridesByName.get(name);
255if (overrideIndex === undefined) {
256overrideIndex = -1;
257}
258const value = (overrideIndex > -1 && source.helm.parameters[overrideIndex].value) || original;
259return {overrideIndex, original, metadata: {name, value}};
260})
261)
262);
263const fileParamsByName = new Map<string, models.HelmFileParameter>();
264(props.details.helm.fileParameters || []).forEach(param => fileParamsByName.set(param.name, param));
265const fileOverridesByName = new Map<string, number>();
266((source.helm && source.helm.fileParameters) || []).forEach((override, i) => fileOverridesByName.set(override.name, i));
267attributes = attributes.concat(
268getParamsEditableItems(
269app,
270'PARAMETERS',
271'spec.source.helm.parameters',
272removedOverrides,
273setRemovedOverrides,
274distinct(fileParamsByName.keys(), fileOverridesByName.keys()).map(name => {
275const param = fileParamsByName.get(name);
276const original = (param && param.path) || '';
277let overrideIndex = fileOverridesByName.get(name);
278if (overrideIndex === undefined) {
279overrideIndex = -1;
280}
281const value = (overrideIndex > -1 && source.helm.fileParameters[overrideIndex].path) || original;
282return {overrideIndex, original, metadata: {name, value}};
283})
284)
285);
286} else if (props.details.type === 'Plugin') {
287attributes.push({
288title: 'NAME',
289view: <div style={{marginTop: 15, marginBottom: 5}}>{ValueEditor(app.spec.source.plugin && app.spec.source.plugin.name, null)}</div>,
290edit: (formApi: FormApi) => (
291<DataLoader load={() => services.authService.plugins()}>
292{(plugins: Plugin[]) => (
293<FormField formApi={formApi} field='spec.source.plugin.name' component={FormSelect} componentProps={{options: plugins.map(p => p.name)}} />
294)}
295</DataLoader>
296)
297});
298attributes.push({
299title: 'ENV',
300view: (
301<div style={{marginTop: 15}}>
302{app.spec.source.plugin &&
303(app.spec.source.plugin.env || []).map(val => (
304<span key={val.name} style={{display: 'block', marginBottom: 5}}>
305{NameValueEditor(val, null)}
306</span>
307))}
308</div>
309),
310edit: (formApi: FormApi) => <FormField field='spec.source.plugin.env' formApi={formApi} component={ArrayInputField} />
311});
312const parametersSet = new Set<string>();
313if (props.details?.plugin?.parametersAnnouncement) {
314for (const announcement of props.details.plugin.parametersAnnouncement) {
315parametersSet.add(announcement.name);
316}
317}
318if (app.spec.source.plugin?.parameters) {
319for (const appParameter of app.spec.source.plugin.parameters) {
320parametersSet.add(appParameter.name);
321}
322}
323
324for (const key of appParamsDeletedState) {
325parametersSet.delete(key);
326}
327parametersSet.forEach(name => {
328const announcement = props.details.plugin.parametersAnnouncement?.find(param => param.name === name);
329const liveParam = app.spec.source.plugin?.parameters?.find(param => param.name === name);
330const pluginIcon =
331announcement && liveParam ? 'This parameter has been provided by plugin, but is overridden in application manifest.' : 'This parameter is provided by the plugin.';
332const isPluginPar = !!announcement;
333if ((announcement?.collectionType === undefined && liveParam?.map) || announcement?.collectionType === 'map') {
334let liveParamMap;
335if (liveParam) {
336liveParamMap = liveParam.map ?? new Map<string, string>();
337}
338const map = concatMaps(liveParamMap ?? announcement?.map, new Map<string, string>());
339const entries = map.entries();
340const items = new Array<NameValue>();
341Array.from(entries).forEach(([key, value]) => items.push({name: key, value: `${value}`}));
342attributes.push({
343title: announcement?.title ?? announcement?.name ?? name,
344customTitle: (
345<span>
346{isPluginPar && <i className='fa solid fa-puzzle-piece' title={pluginIcon} style={{marginRight: 5}} />}
347{announcement?.title ?? announcement?.name ?? name}
348</span>
349),
350view: (
351<div style={{marginTop: 15, marginBottom: 5}}>
352{items.length === 0 && <span style={{color: 'dimgray'}}>-- NO ITEMS --</span>}
353{items.map(val => (
354<span key={val.name} style={{display: 'block', marginBottom: 5}}>
355{NameValueEditor(val)}
356</span>
357))}
358</div>
359),
360edit: (formApi: FormApi) => (
361<FormField
362field='spec.source.plugin.parameters'
363componentProps={{
364name: announcement?.name ?? name,
365defaultVal: announcement?.map,
366isPluginPar,
367setAppParamsDeletedState
368}}
369formApi={formApi}
370component={MapValueField}
371/>
372)
373});
374} else if ((announcement?.collectionType === undefined && liveParam?.array) || announcement?.collectionType === 'array') {
375let liveParamArray;
376if (liveParam) {
377liveParamArray = liveParam?.array ?? [];
378}
379attributes.push({
380title: announcement?.title ?? announcement?.name ?? name,
381customTitle: (
382<span>
383{isPluginPar && <i className='fa-solid fa-puzzle-piece' title={pluginIcon} style={{marginRight: 5}} />}
384{announcement?.title ?? announcement?.name ?? name}
385</span>
386),
387view: (
388<div style={{marginTop: 15, marginBottom: 5}}>
389{(liveParamArray ?? announcement?.array ?? []).length === 0 && <span style={{color: 'dimgray'}}>-- NO ITEMS --</span>}
390{(liveParamArray ?? announcement?.array ?? []).map((val, index) => (
391<span key={index} style={{display: 'block', marginBottom: 5}}>
392{ValueEditor(val, null)}
393</span>
394))}
395</div>
396),
397edit: (formApi: FormApi) => (
398<FormField
399field='spec.source.plugin.parameters'
400componentProps={{
401name: announcement?.name ?? name,
402defaultVal: announcement?.array,
403isPluginPar,
404setAppParamsDeletedState
405}}
406formApi={formApi}
407component={ArrayValueField}
408/>
409)
410});
411} else if (
412(announcement?.collectionType === undefined && liveParam?.string) ||
413announcement?.collectionType === '' ||
414announcement?.collectionType === 'string' ||
415announcement?.collectionType === undefined
416) {
417let liveParamString;
418if (liveParam) {
419liveParamString = liveParam?.string ?? '';
420}
421attributes.push({
422title: announcement?.title ?? announcement?.name ?? name,
423customTitle: (
424<span>
425{isPluginPar && <i className='fa-solid fa-puzzle-piece' title={pluginIcon} style={{marginRight: 5}} />}
426{announcement?.title ?? announcement?.name ?? name}
427</span>
428),
429view: (
430<div
431style={{
432marginTop: 15,
433marginBottom: 5
434}}>
435{ValueEditor(liveParamString ?? announcement?.string, null)}
436</div>
437),
438edit: (formApi: FormApi) => (
439<FormField
440field='spec.source.plugin.parameters'
441componentProps={{
442name: announcement?.name ?? name,
443defaultVal: announcement?.string,
444isPluginPar,
445setAppParamsDeletedState
446}}
447formApi={formApi}
448component={StringValueField}
449/>
450)
451});
452}
453});
454} else if (props.details.type === 'Directory') {
455const directory = source.directory || ({} as ApplicationSourceDirectory);
456attributes.push({
457title: 'DIRECTORY RECURSE',
458view: (!!directory.recurse).toString(),
459edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.source.directory.recurse' component={CheckboxField} />
460});
461attributes.push({
462title: 'TOP-LEVEL ARGUMENTS',
463view: ((directory?.jsonnet && directory?.jsonnet.tlas) || []).map((i, j) => (
464<label key={j}>
465{i.name}='{i.value}' {i.code && 'code'}
466</label>
467)),
468edit: (formApi: FormApi) => <FormField field='spec.source.directory.jsonnet.tlas' formApi={formApi} component={VarsInputField} />
469});
470attributes.push({
471title: 'EXTERNAL VARIABLES',
472view: ((directory.jsonnet && directory.jsonnet.extVars) || []).map((i, j) => (
473<label key={j}>
474{i.name}='{i.value}' {i.code && 'code'}
475</label>
476)),
477edit: (formApi: FormApi) => <FormField field='spec.source.directory.jsonnet.extVars' formApi={formApi} component={VarsInputField} />
478});
479
480attributes.push({
481title: 'INCLUDE',
482view: directory && directory.include,
483edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.source.directory.include' component={Text} />
484});
485
486attributes.push({
487title: 'EXCLUDE',
488view: directory && directory.exclude,
489edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.source.directory.exclude' component={Text} />
490});
491}
492
493return (
494<EditablePanel
495save={
496props.save &&
497(async (input: models.Application) => {
498const src = getAppDefaultSource(input);
499
500function isDefined(item: any) {
501return item !== null && item !== undefined;
502}
503function isDefinedWithVersion(item: any) {
504return item !== null && item !== undefined && item.match(/:/);
505}
506
507if (src.helm && src.helm.parameters) {
508src.helm.parameters = src.helm.parameters.filter(isDefined);
509}
510if (src.kustomize && src.kustomize.images) {
511src.kustomize.images = src.kustomize.images.filter(isDefinedWithVersion);
512}
513
514let params = input.spec?.source?.plugin?.parameters;
515if (params) {
516for (const param of params) {
517if (param.map && param.array) {
518// @ts-ignore
519param.map = param.array.reduce((acc, {name, value}) => {
520// @ts-ignore
521acc[name] = value;
522return acc;
523}, {});
524delete param.array;
525}
526}
527
528params = params.filter(param => !appParamsDeletedState.includes(param.name));
529input.spec.source.plugin.parameters = params;
530}
531if (input.spec.source.helm && input.spec.source.helm.valuesObject) {
532input.spec.source.helm.valuesObject = jsYaml.safeLoad(input.spec.source.helm.values); // Deserialize json
533input.spec.source.helm.values = '';
534}
535await props.save(input, {});
536setRemovedOverrides(new Array<boolean>());
537})
538}
539values={((props.details.plugin || app?.spec?.source?.plugin) && cloneDeep(app)) || app}
540validate={updatedApp => {
541const errors = {} as any;
542
543for (const fieldPath of ['spec.source.directory.jsonnet.tlas', 'spec.source.directory.jsonnet.extVars']) {
544const invalid = ((getNestedField(updatedApp, fieldPath) || []) as Array<models.JsonnetVar>).filter(item => !item.name && !item.code);
545errors[fieldPath] = invalid.length > 0 ? 'All fields must have name' : null;
546}
547
548if (updatedApp.spec.source.helm && updatedApp.spec.source.helm.values) {
549const parsedValues = jsYaml.safeLoad(updatedApp.spec.source.helm.values);
550errors['spec.source.helm.values'] = typeof parsedValues === 'object' ? null : 'Values must be a map';
551}
552
553return errors;
554}}
555onModeSwitch={
556props.details.plugin &&
557(() => {
558setAppParamsDeletedState([]);
559})
560}
561title={props.details.type.toLocaleUpperCase()}
562items={attributes}
563noReadonlyMode={props.noReadonlyMode}
564hasMultipleSources={app.spec.sources && app.spec.sources.length > 0}
565/>
566);
567};
568