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