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