github.com/argoproj/argo-cd/v3@v3.2.1/ui/src/app/applications/components/application-parameters/application-parameters.tsx (about) 1 import {AutocompleteField, DataLoader, ErrorNotification, FormField, FormSelect, getNestedField, NotificationType, SlidingPanel} 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 Expandable, 10 MapValueField, 11 NameValueEditor, 12 StringValueField, 13 NameValue, 14 TagsInputField, 15 ValueEditor, 16 Paginate, 17 RevisionHelpIcon, 18 Revision, 19 Repo, 20 EditablePanel, 21 EditablePanelItem, 22 Spinner 23 } from '../../../shared/components'; 24 import * as models from '../../../shared/models'; 25 import {ApplicationSourceDirectory, Plugin} from '../../../shared/models'; 26 import {services} from '../../../shared/services'; 27 import {ImageTagFieldEditor} from './kustomize'; 28 import * as kustomize from './kustomize-image'; 29 import {VarsInputField} from './vars-input-field'; 30 import {concatMaps} from '../../../shared/utils'; 31 import {deleteSourceAction, getAppDefaultSource, helpTip} from '../utils'; 32 import * as jsYaml from 'js-yaml'; 33 import {RevisionFormField} from '../revision-form-field/revision-form-field'; 34 import classNames from 'classnames'; 35 import {ApplicationParametersSource} from './application-parameters-source'; 36 37 import './application-parameters.scss'; 38 import {AppContext} from '../../../shared/context'; 39 import {SourcePanel} from './source-panel'; 40 41 const TextWithMetadataField = ReactFormField((props: {metadata: {value: string}; fieldApi: FieldApi; className: string}) => { 42 const { 43 fieldApi: {getValue, setValue} 44 } = props; 45 const metadata = getValue() || props.metadata; 46 47 return <input className={props.className} value={metadata.value} onChange={el => setValue({...metadata, value: el.target.value})} />; 48 }); 49 50 function distinct<T>(first: IterableIterator<T>, second: IterableIterator<T>) { 51 return Array.from(new Set(Array.from(first).concat(Array.from(second)))); 52 } 53 54 function overridesFirst(first: {overrideIndex: number; metadata: {name: string}}, second: {overrideIndex: number; metadata: {name: string}}) { 55 if (first.overrideIndex === second.overrideIndex) { 56 return first.metadata.name.localeCompare(second.metadata.name); 57 } 58 if (first.overrideIndex < 0) { 59 return 1; 60 } else if (second.overrideIndex < 0) { 61 return -1; 62 } 63 return first.overrideIndex - second.overrideIndex; 64 } 65 66 function processPath(path: string) { 67 if (path !== null && path !== undefined) { 68 if (path === '.') { 69 return '(root)'; 70 } 71 return path; 72 } 73 return ''; 74 } 75 76 function getParamsEditableItems( 77 app: models.Application, 78 title: string, 79 fieldsPath: string, 80 removedOverrides: boolean[], 81 setRemovedOverrides: React.Dispatch<boolean[]>, 82 params: { 83 key?: string; 84 overrideIndex: number; 85 original: string; 86 metadata: {name: string; value: string}; 87 }[], 88 component: React.ComponentType = TextWithMetadataField 89 ) { 90 return params 91 .sort(overridesFirst) 92 .map((param, i) => ({ 93 key: param.key, 94 title: param.metadata.name, 95 view: ( 96 <span title={param.metadata.value}> 97 {param.overrideIndex > -1 && <span className='fa fa-gavel' title={`Original value: ${param.original}`} />} {param.metadata.value} 98 </span> 99 ), 100 edit: (formApi: FormApi) => { 101 const labelStyle = {position: 'absolute', right: 0, top: 0, zIndex: 11} as any; 102 const overrideRemoved = removedOverrides[i]; 103 const fieldItemPath = `${fieldsPath}[${i}]`; 104 return ( 105 <React.Fragment> 106 {(overrideRemoved && <span>{param.original}</span>) || ( 107 <FormField 108 formApi={formApi} 109 field={fieldItemPath} 110 component={component} 111 componentProps={{ 112 metadata: param.metadata 113 }} 114 /> 115 )} 116 {param.metadata.value !== param.original && !overrideRemoved && ( 117 <a 118 onClick={() => { 119 formApi.setValue(fieldItemPath, null); 120 removedOverrides[i] = true; 121 setRemovedOverrides(removedOverrides); 122 }} 123 style={labelStyle}> 124 Remove override 125 </a> 126 )} 127 {overrideRemoved && ( 128 <a 129 onClick={() => { 130 formApi.setValue(fieldItemPath, getNestedField(app, fieldsPath)[i]); 131 removedOverrides[i] = false; 132 setRemovedOverrides(removedOverrides); 133 }} 134 style={labelStyle}> 135 Keep override 136 </a> 137 )} 138 </React.Fragment> 139 ); 140 } 141 })) 142 .map((item, i) => ({...item, before: (i === 0 && <p style={{marginTop: '1em'}}>{title}</p>) || null})); 143 } 144 145 export const ApplicationParameters = (props: { 146 application: models.Application; 147 details?: models.RepoAppDetails; 148 save?: (application: models.Application, query: {validate?: boolean}) => Promise<any>; 149 noReadonlyMode?: boolean; 150 pageNumber?: number; 151 setPageNumber?: (x: number) => any; 152 collapsedSources?: boolean[]; 153 handleCollapse?: (i: number, isCollapsed: boolean) => void; 154 appContext?: AppContext; 155 tempSource?: models.ApplicationSource; 156 }) => { 157 const app = cloneDeep(props.application); 158 const source = getAppDefaultSource(app); // For source field 159 const appSources = app?.spec.sources; 160 const [removedOverrides, setRemovedOverrides] = React.useState(new Array<boolean>()); 161 const collapsible = props.collapsedSources !== undefined && props.handleCollapse !== undefined; 162 const [createApi, setCreateApi] = React.useState(null); 163 const [isAddingSource, setIsAddingSource] = React.useState(false); 164 const [isSavingSource, setIsSavingSource] = React.useState(false); 165 const [appParamsDeletedState, setAppParamsDeletedState] = React.useState([]); 166 167 if (app.spec.sources?.length > 0 && !props.details) { 168 // For multi-source case only 169 return ( 170 <div className='application-parameters'> 171 <div className='source-panel-buttons'> 172 <button key={'add_source_button'} onClick={() => setIsAddingSource(true)} disabled={false} className='argo-button argo-button--base'> 173 {helpTip('Add a new source and append it to the sources field')} 174 <span style={{marginRight: '8px'}} /> 175 Add Source 176 </button> 177 </div> 178 <Paginate 179 showHeader={false} 180 data={app.spec.sources} 181 page={props.pageNumber} 182 preferencesKey={'5'} 183 onPageChange={page => { 184 props.setPageNumber(page); 185 }}> 186 {data => { 187 const listOfPanels: JSX.Element[] = []; 188 data.forEach(appSource => { 189 const i = app.spec.sources.indexOf(appSource); 190 listOfPanels.push(getEditablePanelForSources(i, appSource)); 191 }); 192 return listOfPanels; 193 }} 194 </Paginate> 195 <SlidingPanel 196 isShown={isAddingSource} 197 onClose={() => setIsAddingSource(false)} 198 header={ 199 <div> 200 <button 201 key={'source_panel_save_button'} 202 className='argo-button argo-button--base' 203 disabled={isSavingSource} 204 onClick={() => createApi && createApi.submitForm(null)}> 205 <Spinner show={isSavingSource} style={{marginRight: '5px'}} /> 206 Save 207 </button>{' '} 208 <button 209 key={'source_panel_cancel_button_'} 210 onClick={() => { 211 setIsAddingSource(false); 212 setIsSavingSource(false); 213 }} 214 className='argo-button argo-button--base-o'> 215 Cancel 216 </button> 217 </div> 218 }> 219 <SourcePanel 220 appCurrent={props.application} 221 getFormApi={api => { 222 setCreateApi(api); 223 }} 224 onSubmitFailure={errors => { 225 props.appContext.apis.notifications.show({ 226 content: 'Cannot add source: ' + errors.toString(), 227 type: NotificationType.Warning 228 }); 229 }} 230 updateApp={async updatedAppSource => { 231 setIsSavingSource(true); 232 props.application.spec.sources.push(updatedAppSource.spec.source); 233 try { 234 await services.applications.update(props.application); 235 setIsAddingSource(false); 236 } catch (e) { 237 props.application.spec.sources.pop(); 238 props.appContext.apis.notifications.show({ 239 content: <ErrorNotification title='Unable to create source' e={e} />, 240 type: NotificationType.Error 241 }); 242 } finally { 243 setIsSavingSource(false); 244 } 245 }} 246 /> 247 </SlidingPanel> 248 </div> 249 ); 250 } else { 251 // For the three other references of ApplicationParameters. They are single source. 252 // Create App, Add source, Rollback and History 253 let attributes: EditablePanelItem[] = []; 254 if (props.details) { 255 return getEditablePanel( 256 gatherDetails( 257 0, 258 props.details, 259 attributes, 260 props.tempSource ? props.tempSource : source, 261 app, 262 setRemovedOverrides, 263 removedOverrides, 264 appParamsDeletedState, 265 setAppParamsDeletedState, 266 false 267 ), 268 props.details 269 ); 270 } else { 271 // For single source field, details page where we have to do the load to retrieve repo details 272 // Input changes frequently due to updates higher in the tree, do not show loading state when reloading 273 return ( 274 <DataLoader noLoaderOnInputChange={true} input={app} load={application => getSingleSource(application)}> 275 {(details: models.RepoAppDetails) => { 276 attributes = []; 277 const attr = gatherDetails( 278 0, 279 details, 280 attributes, 281 source, 282 app, 283 setRemovedOverrides, 284 removedOverrides, 285 appParamsDeletedState, 286 setAppParamsDeletedState, 287 false 288 ); 289 return getEditablePanel(attr, details); 290 }} 291 </DataLoader> 292 ); 293 } 294 } 295 296 // Collapse button is separate 297 function getEditablePanelForSources(index: number, appSource: models.ApplicationSource): JSX.Element { 298 return (collapsible && props.collapsedSources[index] === undefined) || props.collapsedSources[index] ? ( 299 <div 300 key={'app_params_collapsed_' + index} 301 className='settings-overview__redirect-panel' 302 style={{marginTop: 0}} 303 onClick={() => { 304 const currentState = props.collapsedSources[index] !== undefined ? props.collapsedSources[index] : true; 305 props.handleCollapse(index, !currentState); 306 }}> 307 <div className='editable-panel__collapsible-button'> 308 <i className={`fa fa-angle-down filter__collapse editable-panel__collapsible-button__override`} /> 309 </div> 310 <div className='settings-overview__redirect-panel__content'> 311 <div className='settings-overview__redirect-panel__title'>Source {index + 1 + (appSource.name ? ' - ' + appSource.name : '') + ': ' + appSource.repoURL}</div> 312 <div className='settings-overview__redirect-panel__description'> 313 {(appSource.path ? 'PATH=' + appSource.path : '') + (appSource.targetRevision ? (appSource.path ? ', ' : '') + 'REVISION=' + appSource.targetRevision : '')} 314 </div> 315 </div> 316 </div> 317 ) : ( 318 <div key={'app_params_expanded_' + index} className={classNames('white-box', 'editable-panel')} style={{marginBottom: '18px', paddingBottom: '20px'}}> 319 <div key={'app_params_panel_' + index} className='white-box__details'> 320 {collapsible && ( 321 <div className='editable-panel__collapsible-button'> 322 <i 323 className={`fa fa-angle-up filter__collapse editable-panel__collapsible-button__override`} 324 onClick={() => { 325 props.handleCollapse(index, !props.collapsedSources[index]); 326 }} 327 /> 328 </div> 329 )} 330 <DataLoader 331 key={'app_params_source_' + index} 332 input={app.spec.sources[index]} 333 load={src => getSourceFromAppSources(src, app.metadata.name, app.spec.project, index, 0)}> 334 {(details: models.RepoAppDetails) => getEditablePanelForOneSource(details, index, app.spec.sources[index])} 335 </DataLoader> 336 </div> 337 </div> 338 ); 339 } 340 341 function getEditablePanel(items: EditablePanelItem[], repoAppDetails: models.RepoAppDetails): any { 342 return ( 343 <div className='application-parameters'> 344 <EditablePanel 345 save={ 346 props.save && 347 (async (input: models.Application) => { 348 const updatedSrc = input.spec.source; 349 350 function isDefined(item: any) { 351 return item !== null && item !== undefined; 352 } 353 function isDefinedWithVersion(item: any) { 354 return item !== null && item !== undefined && item.match(/:/); 355 } 356 if (updatedSrc && updatedSrc.helm?.parameters) { 357 updatedSrc.helm.parameters = updatedSrc.helm.parameters.filter(isDefined); 358 } 359 if (updatedSrc && updatedSrc.kustomize?.images) { 360 updatedSrc.kustomize.images = updatedSrc.kustomize.images.filter(isDefinedWithVersion); 361 } 362 363 let params = input.spec?.source?.plugin?.parameters; 364 if (params) { 365 for (const param of params) { 366 if (param.map && param.array) { 367 // eslint-disable-next-line @typescript-eslint/ban-ts-comment 368 // @ts-ignore 369 param.map = param.array.reduce((acc, {name, value}) => { 370 // eslint-disable-next-line @typescript-eslint/ban-ts-comment 371 // @ts-ignore 372 acc[name] = value; 373 return acc; 374 }, {}); 375 delete param.array; 376 } 377 } 378 params = params.filter(param => !appParamsDeletedState.includes(param.name)); 379 input.spec.source.plugin.parameters = params; 380 } 381 if (input.spec.source && input.spec.source.helm?.valuesObject) { 382 input.spec.source.helm.valuesObject = jsYaml.load(input.spec.source.helm.values); // Deserialize json 383 input.spec.source.helm.values = ''; 384 } 385 await props.save(input, {}); 386 setRemovedOverrides(new Array<boolean>()); 387 }) 388 } 389 values={((repoAppDetails?.plugin || app?.spec?.source?.plugin) && cloneDeep(app)) || app} 390 validate={updatedApp => { 391 const errors = {} as any; 392 393 for (const fieldPath of ['spec.source.directory.jsonnet.tlas', 'spec.source.directory.jsonnet.extVars']) { 394 const invalid = ((getNestedField(updatedApp, fieldPath) || []) as Array<models.JsonnetVar>).filter(item => !item.name && !item.code); 395 errors[fieldPath] = invalid.length > 0 ? 'All fields must have name' : null; 396 } 397 398 if (updatedApp.spec.source && updatedApp.spec.source.helm?.values) { 399 const parsedValues = jsYaml.load(updatedApp.spec.source.helm.values); 400 errors['spec.source.helm.values'] = typeof parsedValues === 'object' ? null : 'Values must be a map'; 401 } 402 403 return errors; 404 }} 405 onModeSwitch={ 406 repoAppDetails?.plugin && 407 (() => { 408 setAppParamsDeletedState([]); 409 }) 410 } 411 title={repoAppDetails?.type?.toLocaleUpperCase()} 412 items={items as EditablePanelItem[]} 413 noReadonlyMode={props.noReadonlyMode} 414 hasMultipleSources={false} 415 /> 416 </div> 417 ); 418 } 419 420 function getEditablePanelForOneSource(repoAppDetails: models.RepoAppDetails, ind: number, src: models.ApplicationSource): any { 421 let floatingTitle: string; 422 const lowerPanelAttributes: EditablePanelItem[] = []; 423 const upperPanelAttributes: EditablePanelItem[] = []; 424 425 const upperPanel = gatherCoreSourceDetails(ind, upperPanelAttributes, appSources[ind], app); 426 const lowerPanel = gatherDetails( 427 ind, 428 repoAppDetails, 429 lowerPanelAttributes, 430 appSources[ind], 431 app, 432 setRemovedOverrides, 433 removedOverrides, 434 appParamsDeletedState, 435 setAppParamsDeletedState, 436 true 437 ); 438 439 if (repoAppDetails.type === 'Directory') { 440 floatingTitle = 441 'Source ' + 442 (ind + 1) + 443 ': TYPE=' + 444 repoAppDetails.type + 445 ', URL=' + 446 src.repoURL + 447 (repoAppDetails.path ? ', PATH=' + repoAppDetails.path : '') + 448 (src.targetRevision ? ', TARGET REVISION=' + src.targetRevision : ''); 449 } else if (repoAppDetails.type === 'Helm') { 450 floatingTitle = 451 'Source ' + 452 (ind + 1) + 453 ': TYPE=' + 454 repoAppDetails.type + 455 ', URL=' + 456 src.repoURL + 457 (src.chart ? ', CHART=' + src.chart + ':' + src.targetRevision : '') + 458 (src.path ? ', PATH=' + src.path : '') + 459 (src.targetRevision ? ', REVISION=' + src.targetRevision : ''); 460 } else if (repoAppDetails.type === 'Kustomize') { 461 floatingTitle = 462 'Source ' + 463 (ind + 1) + 464 ': TYPE=' + 465 repoAppDetails.type + 466 ', URL=' + 467 src.repoURL + 468 (repoAppDetails.path ? ', PATH=' + repoAppDetails.path : '') + 469 (src.targetRevision ? ', TARGET REVISION=' + src.targetRevision : ''); 470 } else if (repoAppDetails.type === 'Plugin') { 471 floatingTitle = 472 'Source ' + 473 (ind + 1) + 474 ': TYPE=' + 475 repoAppDetails.type + 476 ', URL=' + 477 src.repoURL + 478 (repoAppDetails.path ? ', PATH=' + repoAppDetails.path : '') + 479 (src.targetRevision ? ', TARGET REVISION=' + src.targetRevision : ''); 480 } 481 return ( 482 <ApplicationParametersSource 483 index={ind} 484 saveTop={props.save} 485 saveBottom={ 486 props.save && 487 (async (input: models.Application) => { 488 const appSrc = input.spec.sources[ind]; 489 490 function isDefined(item: any) { 491 return item !== null && item !== undefined; 492 } 493 function isDefinedWithVersion(item: any) { 494 return item !== null && item !== undefined && item.match(/:/); 495 } 496 497 if (appSrc.helm && appSrc.helm.parameters) { 498 appSrc.helm.parameters = appSrc.helm.parameters.filter(isDefined); 499 } 500 if (appSrc.kustomize && appSrc.kustomize.images) { 501 appSrc.kustomize.images = appSrc.kustomize.images.filter(isDefinedWithVersion); 502 } 503 504 let params = input.spec?.sources[ind]?.plugin?.parameters; 505 if (params) { 506 for (const param of params) { 507 if (param.map && param.array) { 508 // eslint-disable-next-line @typescript-eslint/ban-ts-comment 509 // @ts-ignore 510 param.map = param.array.reduce((acc, {name, value}) => { 511 // eslint-disable-next-line @typescript-eslint/ban-ts-comment 512 // @ts-ignore 513 acc[name] = value; 514 return acc; 515 }, {}); 516 delete param.array; 517 } 518 } 519 520 params = params.filter(param => !appParamsDeletedState.includes(param.name)); 521 appSrc.plugin.parameters = params; 522 } 523 if (appSrc.helm && appSrc.helm.valuesObject) { 524 appSrc.helm.valuesObject = jsYaml.load(appSrc.helm.values); // Deserialize json 525 appSrc.helm.values = ''; 526 } 527 528 await props.save(input, {}); 529 setRemovedOverrides(new Array<boolean>()); 530 }) 531 } 532 valuesTop={(app?.spec?.sources && (repoAppDetails.plugin || app?.spec?.sources[ind]?.plugin) && cloneDeep(app)) || app} 533 valuesBottom={(app?.spec?.sources && (repoAppDetails.plugin || app?.spec?.sources[ind]?.plugin) && cloneDeep(app)) || app} 534 validateTop={updatedApp => { 535 const errors = [] as any; 536 const repoURL = updatedApp.spec.sources[ind].repoURL; 537 if (repoURL === null || repoURL.length === 0) { 538 errors['spec.sources[' + ind + '].repoURL'] = 'The source repo URL cannot be empty'; 539 } else { 540 errors['spec.sources[' + ind + '].repoURL'] = null; 541 } 542 return errors; 543 }} 544 validateBottom={updatedApp => { 545 const errors = {} as any; 546 547 for (const fieldPath of ['spec.sources[' + ind + '].directory.jsonnet.tlas', 'spec.sources[' + ind + '].directory.jsonnet.extVars']) { 548 const invalid = ((getNestedField(updatedApp, fieldPath) || []) as Array<models.JsonnetVar>).filter(item => !item.name && !item.code); 549 errors[fieldPath] = invalid.length > 0 ? 'All fields must have name' : null; 550 } 551 552 if (updatedApp.spec.sources[ind].helm?.values) { 553 const parsedValues = jsYaml.load(updatedApp.spec.sources[ind].helm.values); 554 errors['spec.sources[' + ind + '].helm.values'] = typeof parsedValues === 'object' ? null : 'Values must be a map'; 555 } 556 557 return errors; 558 }} 559 onModeSwitch={ 560 repoAppDetails.plugin && 561 (() => { 562 setAppParamsDeletedState([]); 563 }) 564 } 565 titleBottom={repoAppDetails.type.toLocaleUpperCase()} 566 titleTop={'SOURCE ' + (ind + 1)} 567 floatingTitle={floatingTitle ? floatingTitle : null} 568 itemsBottom={lowerPanel as EditablePanelItem[]} 569 itemsTop={upperPanel as EditablePanelItem[]} 570 noReadonlyMode={props.noReadonlyMode} 571 collapsible={collapsible} 572 numberOfSources={app?.spec?.sources.length} 573 deleteSource={() => { 574 deleteSourceAction(app, app.spec.sources.at(ind), props.appContext); 575 }} 576 /> 577 ); 578 } 579 }; 580 581 function gatherCoreSourceDetails(i: number, attributes: EditablePanelItem[], source: models.ApplicationSource, app: models.Application): EditablePanelItem[] { 582 const hasMultipleSources = app.spec.sources && app.spec.sources.length > 0; 583 // eslint-disable-next-line no-prototype-builtins 584 const isHelm = source.hasOwnProperty('chart'); 585 const repoUrlField = 'spec.sources[' + i + '].repoURL'; 586 const sourcesPathField = 'spec.sources[' + i + '].path'; 587 const refField = 'spec.sources[' + i + '].ref'; 588 const nameField = 'spec.sources[' + i + '].name'; 589 const chartField = 'spec.sources[' + i + '].chart'; 590 const revisionField = 'spec.sources[' + i + '].targetRevision'; 591 // For single source apps using the source field, these fields are shown in the Summary tab. 592 if (hasMultipleSources) { 593 attributes.push({ 594 title: 'REPO URL', 595 view: <Repo url={source.repoURL} />, 596 edit: (formApi: FormApi) => <FormField formApi={formApi} field={repoUrlField} component={Text} /> 597 }); 598 attributes.push({ 599 title: 'NAME', 600 view: <span>{source?.name}</span>, 601 edit: (formApi: FormApi) => <FormField formApi={formApi} field={nameField} component={Text} /> 602 }); 603 if (isHelm) { 604 attributes.push({ 605 title: 'CHART', 606 view: ( 607 <span> 608 {source.chart}:{source.targetRevision} 609 </span> 610 ), 611 edit: (formApi: FormApi) => ( 612 <DataLoader input={{repoURL: source.repoURL}} load={src => services.repos.charts(src.repoURL).catch(() => new Array<models.HelmChart>())}> 613 {(charts: models.HelmChart[]) => ( 614 <div className='row'> 615 <div className='columns small-8'> 616 <FormField 617 formApi={formApi} 618 field={chartField} 619 component={AutocompleteField} 620 componentProps={{ 621 items: charts.map(chart => chart.name), 622 filterSuggestions: true 623 }} 624 /> 625 </div> 626 <DataLoader 627 input={{charts, chart: source.chart}} 628 load={async data => { 629 const chartInfo = data.charts.find(chart => chart.name === data.chart); 630 return (chartInfo && chartInfo.versions) || new Array<string>(); 631 }}> 632 {(versions: string[]) => ( 633 <div className='columns small-4'> 634 <FormField 635 formApi={formApi} 636 field={revisionField} 637 component={AutocompleteField} 638 componentProps={{ 639 items: versions 640 }} 641 /> 642 <RevisionHelpIcon type='helm' top='0' /> 643 </div> 644 )} 645 </DataLoader> 646 </div> 647 )} 648 </DataLoader> 649 ) 650 }); 651 } else { 652 const targetRevision = source ? source.targetRevision || 'HEAD' : 'Unknown'; 653 attributes.push({ 654 title: 'TARGET REVISION', 655 view: <Revision repoUrl={source?.repoURL} revision={targetRevision} />, 656 edit: (formApi: FormApi) => <RevisionFormField helpIconTop={'0'} hideLabel={true} formApi={formApi} repoURL={source?.repoURL} fieldValue={revisionField} /> 657 }); 658 attributes.push({ 659 title: 'PATH', 660 view: ( 661 <Revision repoUrl={source?.repoURL} revision={targetRevision} path={source?.path} isForPath={true}> 662 {processPath(source?.path)} 663 </Revision> 664 ), 665 edit: (formApi: FormApi) => <FormField formApi={formApi} field={sourcesPathField} component={Text} /> 666 }); 667 attributes.push({ 668 title: 'REF', 669 view: <span>{source?.ref}</span>, 670 edit: (formApi: FormApi) => <FormField formApi={formApi} field={refField} component={Text} /> 671 }); 672 } 673 } 674 return attributes; 675 } 676 677 function gatherDetails( 678 ind: number, 679 repoDetails: models.RepoAppDetails, 680 attributes: EditablePanelItem[], 681 source: models.ApplicationSource, 682 app: models.Application, 683 setRemovedOverrides: any, 684 removedOverrides: any, 685 appParamsDeletedState: any[], 686 setAppParamsDeletedState: any, 687 isMultiSource: boolean 688 ): EditablePanelItem[] { 689 if (repoDetails.type === 'Kustomize' && repoDetails.kustomize) { 690 attributes.push({ 691 title: 'VERSION', 692 view: (source.kustomize && source.kustomize.version) || <span>default</span>, 693 edit: (formApi: FormApi) => ( 694 <DataLoader load={() => services.authService.settings()}> 695 {settings => 696 ((settings.kustomizeVersions || []).length > 0 && ( 697 <FormField 698 formApi={formApi} 699 field={isMultiSource ? 'spec.sources[' + ind + '].kustomize.version' : 'spec.source.kustomize.version'} 700 component={AutocompleteField} 701 componentProps={{items: settings.kustomizeVersions}} 702 /> 703 )) || <span>default</span> 704 } 705 </DataLoader> 706 ) 707 }); 708 709 attributes.push({ 710 title: 'NAME PREFIX', 711 view: source.kustomize && source.kustomize.namePrefix, 712 edit: (formApi: FormApi) => ( 713 <FormField formApi={formApi} field={isMultiSource ? 'spec.sources[' + ind + '].kustomize.namePrefix' : 'spec.source.kustomize.namePrefix'} component={Text} /> 714 ) 715 }); 716 717 attributes.push({ 718 title: 'NAME SUFFIX', 719 view: source.kustomize && source.kustomize.nameSuffix, 720 edit: (formApi: FormApi) => ( 721 <FormField formApi={formApi} field={isMultiSource ? 'spec.sources[' + ind + '].kustomize.nameSuffix' : 'spec.source.kustomize.nameSuffix'} component={Text} /> 722 ) 723 }); 724 725 attributes.push({ 726 title: 'NAMESPACE', 727 view: source.kustomize && source.kustomize.namespace, 728 edit: (formApi: FormApi) => ( 729 <FormField formApi={formApi} field={isMultiSource ? 'spec.sources[' + ind + '].kustomize.namespace' : 'spec.source.kustomize.namespace'} component={Text} /> 730 ) 731 }); 732 733 const srcImages = ((repoDetails && repoDetails.kustomize && repoDetails.kustomize.images) || []).map(val => kustomize.parse(val)); 734 const images = ((source.kustomize && source.kustomize.images) || []).map(val => kustomize.parse(val)); 735 736 if (srcImages.length > 0) { 737 const imagesByName = new Map<string, kustomize.Image>(); 738 srcImages.forEach(img => imagesByName.set(img.name, img)); 739 740 const overridesByName = new Map<string, number>(); 741 images.forEach((override, i) => overridesByName.set(override.name, i)); 742 743 attributes = attributes.concat( 744 getParamsEditableItems( 745 app, 746 'IMAGES', 747 isMultiSource ? 'spec.sources[' + ind + '].kustomize.images' : 'spec.source.kustomize.images', 748 removedOverrides, 749 setRemovedOverrides, 750 distinct(imagesByName.keys(), overridesByName.keys()).map(name => { 751 const param = imagesByName.get(name); 752 const original = param && kustomize.format(param); 753 let overrideIndex = overridesByName.get(name); 754 if (overrideIndex === undefined) { 755 overrideIndex = -1; 756 } 757 const value = (overrideIndex > -1 && kustomize.format(images[overrideIndex])) || original; 758 return {overrideIndex, original, metadata: {name, value}}; 759 }), 760 ImageTagFieldEditor 761 ) 762 ); 763 } 764 } else if (repoDetails.type === 'Helm' && repoDetails.helm) { 765 const isValuesObject = source?.helm?.valuesObject; 766 const helmValues = isValuesObject ? jsYaml.dump(source.helm.valuesObject) : source?.helm?.values; 767 attributes.push({ 768 title: 'VALUES FILES', 769 view: (source.helm && (source.helm.valueFiles || []).join(', ')) || 'No values files selected', 770 edit: (formApi: FormApi) => ( 771 <FormField 772 formApi={formApi} 773 field={isMultiSource ? 'spec.sources[' + ind + '].helm.valueFiles' : 'spec.source.helm.valueFiles'} 774 component={TagsInputField} 775 componentProps={{ 776 options: repoDetails.helm.valueFiles, 777 noTagsLabel: 'No values files selected' 778 }} 779 /> 780 ) 781 }); 782 attributes.push({ 783 title: 'VALUES', 784 view: source.helm && ( 785 <Expandable> 786 <pre>{helmValues}</pre> 787 </Expandable> 788 ), 789 edit: (formApi: FormApi) => { 790 // In case source.helm.valuesObject is set, set source.helm.values to its value 791 if (source.helm) { 792 source.helm.values = helmValues; 793 } 794 795 return ( 796 <div> 797 <pre> 798 <FormField formApi={formApi} field={isMultiSource ? 'spec.sources[' + ind + '].helm.values' : 'spec.source.helm.values'} component={TextArea} /> 799 </pre> 800 </div> 801 ); 802 } 803 }); 804 const paramsByName = new Map<string, models.HelmParameter>(); 805 (repoDetails.helm.parameters || []).forEach(param => paramsByName.set(param.name, param)); 806 const overridesByName = new Map<string, number>(); 807 ((source.helm && source.helm.parameters) || []).forEach((override, i) => overridesByName.set(override.name, i)); 808 attributes = attributes.concat( 809 getParamsEditableItems( 810 app, 811 'PARAMETERS', 812 isMultiSource ? 'spec.sources[' + ind + '].helm.parameters' : 'spec.source.helm.parameters', 813 removedOverrides, 814 setRemovedOverrides, 815 distinct(paramsByName.keys(), overridesByName.keys()).map(name => { 816 const param = paramsByName.get(name); 817 const original = (param && param.value) || ''; 818 let overrideIndex = overridesByName.get(name); 819 if (overrideIndex === undefined) { 820 overrideIndex = -1; 821 } 822 const value = (overrideIndex > -1 && source.helm.parameters[overrideIndex].value) || original; 823 return {overrideIndex, original, metadata: {name, value}}; 824 }) 825 ) 826 ); 827 const fileParamsByName = new Map<string, models.HelmFileParameter>(); 828 (repoDetails.helm.fileParameters || []).forEach(param => fileParamsByName.set(param.name, param)); 829 const fileOverridesByName = new Map<string, number>(); 830 ((source.helm && source.helm.fileParameters) || []).forEach((override, i) => fileOverridesByName.set(override.name, i)); 831 attributes = attributes.concat( 832 getParamsEditableItems( 833 app, 834 'PARAMETERS', 835 isMultiSource ? 'spec.sources[' + ind + '].helm.parameters' : 'spec.source.helm.parameters', 836 removedOverrides, 837 setRemovedOverrides, 838 distinct(fileParamsByName.keys(), fileOverridesByName.keys()).map(name => { 839 const param = fileParamsByName.get(name); 840 const original = (param && param.path) || ''; 841 let overrideIndex = fileOverridesByName.get(name); 842 if (overrideIndex === undefined) { 843 overrideIndex = -1; 844 } 845 const value = (overrideIndex > -1 && source.helm.fileParameters[overrideIndex].path) || original; 846 return {overrideIndex, original, metadata: {name, value}}; 847 }) 848 ) 849 ); 850 } else if (repoDetails.type === 'Plugin') { 851 attributes.push({ 852 title: 'NAME', 853 view: <div style={{marginTop: 15, marginBottom: 5}}>{ValueEditor(app.spec.source?.plugin?.name, null)}</div>, 854 edit: (formApi: FormApi) => ( 855 <DataLoader load={() => services.authService.plugins()}> 856 {(plugins: Plugin[]) => ( 857 <FormField 858 formApi={formApi} 859 field={isMultiSource ? 'spec.sources[' + ind + '].plugin.name' : 'spec.source.plugin.name'} 860 component={FormSelect} 861 componentProps={{options: plugins.map(p => p.name)}} 862 /> 863 )} 864 </DataLoader> 865 ) 866 }); 867 attributes.push({ 868 title: 'ENV', 869 view: ( 870 <div style={{marginTop: 15}}> 871 {(app.spec.source?.plugin?.env || []).map(val => ( 872 <span key={val.name} style={{display: 'block', marginBottom: 5}}> 873 {NameValueEditor(val, null)} 874 </span> 875 ))} 876 </div> 877 ), 878 edit: (formApi: FormApi) => ( 879 <FormField field={isMultiSource ? 'spec.sources[' + ind + '].plugin.env' : 'spec.source.plugin.env'} formApi={formApi} component={ArrayInputField} /> 880 ) 881 }); 882 const parametersSet = new Set<string>(); 883 if (repoDetails?.plugin?.parametersAnnouncement) { 884 for (const announcement of repoDetails.plugin.parametersAnnouncement) { 885 parametersSet.add(announcement.name); 886 } 887 } 888 if (app.spec.source?.plugin?.parameters) { 889 for (const appParameter of app.spec.source.plugin.parameters) { 890 parametersSet.add(appParameter.name); 891 } 892 } 893 894 for (const key of appParamsDeletedState) { 895 parametersSet.delete(key); 896 } 897 parametersSet.forEach(name => { 898 const announcement = repoDetails.plugin.parametersAnnouncement?.find(param => param.name === name); 899 const liveParam = app.spec.source?.plugin?.parameters?.find(param => param.name === name); 900 const pluginIcon = 901 announcement && liveParam ? 'This parameter has been provided by plugin, but is overridden in application manifest.' : 'This parameter is provided by the plugin.'; 902 const isPluginPar = !!announcement; 903 if ((announcement?.collectionType === undefined && liveParam?.map) || announcement?.collectionType === 'map') { 904 let liveParamMap; 905 if (liveParam) { 906 liveParamMap = liveParam.map ?? new Map<string, string>(); 907 } 908 const map = concatMaps(liveParamMap ?? announcement?.map, new Map<string, string>()); 909 const entries = map.entries(); 910 const items = new Array<NameValue>(); 911 Array.from(entries).forEach(([key, value]) => items.push({name: key, value: `${value}`})); 912 attributes.push({ 913 title: announcement?.title ?? announcement?.name ?? name, 914 customTitle: ( 915 <span> 916 {isPluginPar && <i className='fa solid fa-puzzle-piece' title={pluginIcon} style={{marginRight: 5}} />} 917 {announcement?.title ?? announcement?.name ?? name} 918 </span> 919 ), 920 view: ( 921 <div style={{marginTop: 15, marginBottom: 5}}> 922 {items.length === 0 && <span style={{color: 'dimgray'}}>-- NO ITEMS --</span>} 923 {items.map(val => ( 924 <span key={val.name} style={{display: 'block', marginBottom: 5}}> 925 {NameValueEditor(val)} 926 </span> 927 ))} 928 </div> 929 ), 930 edit: (formApi: FormApi) => ( 931 <FormField 932 field={isMultiSource ? 'spec.sources[' + ind + '].plugin.parameters' : 'spec.source.plugin.parameters'} 933 componentProps={{ 934 name: announcement?.name ?? name, 935 defaultVal: announcement?.map, 936 isPluginPar, 937 setAppParamsDeletedState 938 }} 939 formApi={formApi} 940 component={MapValueField} 941 /> 942 ) 943 }); 944 } else if ((announcement?.collectionType === undefined && liveParam?.array) || announcement?.collectionType === 'array') { 945 let liveParamArray; 946 if (liveParam) { 947 liveParamArray = liveParam?.array ?? []; 948 } 949 attributes.push({ 950 title: announcement?.title ?? announcement?.name ?? name, 951 customTitle: ( 952 <span> 953 {isPluginPar && <i className='fa-solid fa-puzzle-piece' title={pluginIcon} style={{marginRight: 5}} />} 954 {announcement?.title ?? announcement?.name ?? name} 955 </span> 956 ), 957 view: ( 958 <div style={{marginTop: 15, marginBottom: 5}}> 959 {(liveParamArray ?? announcement?.array ?? []).length === 0 && <span style={{color: 'dimgray'}}>-- NO ITEMS --</span>} 960 {(liveParamArray ?? announcement?.array ?? []).map((val, index) => ( 961 <span key={index} style={{display: 'block', marginBottom: 5}}> 962 {ValueEditor(val, null)} 963 </span> 964 ))} 965 </div> 966 ), 967 edit: (formApi: FormApi) => ( 968 <FormField 969 field={isMultiSource ? 'spec.sources[' + ind + '].plugin.parameters' : 'spec.source.plugin.parameters'} 970 componentProps={{ 971 name: announcement?.name ?? name, 972 defaultVal: announcement?.array, 973 isPluginPar, 974 setAppParamsDeletedState 975 }} 976 formApi={formApi} 977 component={ArrayValueField} 978 /> 979 ) 980 }); 981 } else if ( 982 (announcement?.collectionType === undefined && liveParam?.string) || 983 announcement?.collectionType === '' || 984 announcement?.collectionType === 'string' || 985 announcement?.collectionType === undefined 986 ) { 987 let liveParamString; 988 if (liveParam) { 989 liveParamString = liveParam?.string ?? ''; 990 } 991 attributes.push({ 992 title: announcement?.title ?? announcement?.name ?? name, 993 customTitle: ( 994 <span> 995 {isPluginPar && <i className='fa-solid fa-puzzle-piece' title={pluginIcon} style={{marginRight: 5}} />} 996 {announcement?.title ?? announcement?.name ?? name} 997 </span> 998 ), 999 view: ( 1000 <div 1001 style={{ 1002 marginTop: 15, 1003 marginBottom: 5 1004 }}> 1005 {ValueEditor(liveParamString ?? announcement?.string, null)} 1006 </div> 1007 ), 1008 edit: (formApi: FormApi) => ( 1009 <FormField 1010 field={isMultiSource ? 'spec.sources[' + ind + '].plugin.parameters' : 'spec.source.plugin.parameters'} 1011 componentProps={{ 1012 name: announcement?.name ?? name, 1013 defaultVal: announcement?.string, 1014 isPluginPar, 1015 setAppParamsDeletedState 1016 }} 1017 formApi={formApi} 1018 component={StringValueField} 1019 /> 1020 ) 1021 }); 1022 } 1023 }); 1024 } else if (repoDetails.type === 'Directory') { 1025 const directory = source.directory || ({} as ApplicationSourceDirectory); 1026 const fieldValue = isMultiSource ? 'spec.sources[' + ind + '].directory.recurse' : 'spec.source.directory.recurse'; 1027 attributes.push({ 1028 title: 'DIRECTORY RECURSE', 1029 view: (!!directory.recurse).toString(), 1030 edit: (formApi: FormApi) => <FormField formApi={formApi} field={fieldValue} component={CheckboxField} /> 1031 }); 1032 attributes.push({ 1033 title: 'TOP-LEVEL ARGUMENTS', 1034 view: ((directory?.jsonnet && directory?.jsonnet.tlas) || []).map((i, j) => ( 1035 <label key={j}> 1036 {i.name}='{i.value}' {i.code && 'code'} 1037 </label> 1038 )), 1039 edit: (formApi: FormApi) => ( 1040 <FormField 1041 field={isMultiSource ? 'spec.sources[' + ind + '].directory.jsonnet.tlas' : 'spec.source.directory.jsonnet.tlas'} 1042 formApi={formApi} 1043 component={VarsInputField} 1044 /> 1045 ) 1046 }); 1047 attributes.push({ 1048 title: 'EXTERNAL VARIABLES', 1049 view: ((directory.jsonnet && directory.jsonnet.extVars) || []).map((i, j) => ( 1050 <label key={j}> 1051 {i.name}='{i.value}' {i.code && 'code'} 1052 </label> 1053 )), 1054 edit: (formApi: FormApi) => ( 1055 <FormField 1056 field={isMultiSource ? 'spec.sources[' + ind + '].directory.jsonnet.extVars' : 'spec.source.directory.jsonnet.extVars'} 1057 formApi={formApi} 1058 component={VarsInputField} 1059 /> 1060 ) 1061 }); 1062 1063 attributes.push({ 1064 title: 'INCLUDE', 1065 view: directory && directory.include, 1066 edit: (formApi: FormApi) => ( 1067 <FormField formApi={formApi} field={isMultiSource ? 'spec.sources[' + ind + '].directory.include' : 'spec.source.directory.include'} component={Text} /> 1068 ) 1069 }); 1070 1071 attributes.push({ 1072 title: 'EXCLUDE', 1073 view: directory && directory.exclude, 1074 edit: (formApi: FormApi) => ( 1075 <FormField formApi={formApi} field={isMultiSource ? 'spec.sources[' + ind + '].directory.exclude' : 'spec.source.directory.exclude'} component={Text} /> 1076 ) 1077 }); 1078 } 1079 return attributes; 1080 } 1081 1082 // For Sources field. Get one source with index i from the list 1083 async function getSourceFromAppSources(aSource: models.ApplicationSource, name: string, project: string, index: number, version: number) { 1084 const repoDetail = await services.repos.appDetails(aSource, name, project, index, version).catch(() => ({ 1085 type: 'Directory' as models.AppSourceType, 1086 path: aSource.path 1087 })); 1088 return repoDetail; 1089 } 1090 1091 // Delete when source field is removed 1092 async function getSingleSource(app: models.Application) { 1093 if (app.spec.source || app.spec.sourceHydrator) { 1094 const repoDetail = await services.repos.appDetails(getAppDefaultSource(app), app.metadata.name, app.spec.project, 0, 0).catch(() => ({ 1095 type: 'Directory' as models.AppSourceType, 1096 path: getAppDefaultSource(app).path 1097 })); 1098 return repoDetail; 1099 } 1100 return null; 1101 }