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