github.com/argoproj/argo-cd@v1.8.7/ui/src/app/applications/components/application-summary/application-summary.tsx (about) 1 import {AutocompleteField, DropDownMenu, FormField, FormSelect, HelpIcon, PopupApi} from 'argo-ui'; 2 import * as React from 'react'; 3 import {FormApi, Text} from 'react-form'; 4 import {Cluster, DataLoader, EditablePanel, EditablePanelItem, Expandable, MapInputField, NumberField, Repo, Revision, RevisionHelpIcon} from '../../../shared/components'; 5 import {BadgePanel, Spinner} from '../../../shared/components'; 6 import {Consumer} from '../../../shared/context'; 7 import * as models from '../../../shared/models'; 8 import {services} from '../../../shared/services'; 9 10 import {ApplicationSyncOptionsField} from '../application-sync-options'; 11 import {RevisionFormField} from '../revision-form-field/revision-form-field'; 12 import {ComparisonStatusIcon, HealthStatusIcon, syncStatusMessage} from '../utils'; 13 14 require('./application-summary.scss'); 15 16 const urlPattern = new RegExp( 17 new RegExp( 18 // tslint:disable-next-line:max-line-length 19 /^(https?:\/\/(?:www\.|(?!www))[a-z0-9][a-z0-9-]+[a-z0-9]\.[^\s]{2,}|www\.[a-z0-9][a-z0-9-]+[a-z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-z0-9]+\.[^\s]{2,}|www\.[a-z0-9]+\.[^\s]{2,})$/, 20 'gi' 21 ) 22 ); 23 24 function swap(array: any[], a: number, b: number) { 25 array = array.slice(); 26 [array[a], array[b]] = [array[b], array[a]]; 27 return array; 28 } 29 30 export const ApplicationSummary = (props: {app: models.Application; updateApp: (app: models.Application) => Promise<any>}) => { 31 const app = JSON.parse(JSON.stringify(props.app)) as models.Application; 32 const isHelm = app.spec.source.hasOwnProperty('chart'); 33 const initialState = app.spec.destination.server === undefined ? 'NAME' : 'URL'; 34 const [destFormat, setDestFormat] = React.useState(initialState); 35 const [changeSync, setChangeSync] = React.useState(false); 36 const attributes = [ 37 { 38 title: 'PROJECT', 39 view: <a href={'/settings/projects/' + app.spec.project}>{app.spec.project}</a>, 40 edit: (formApi: FormApi) => ( 41 <DataLoader load={() => services.projects.list('items.metadata.name').then(projs => projs.map(item => item.metadata.name))}> 42 {projects => <FormField formApi={formApi} field='spec.project' component={FormSelect} componentProps={{options: projects}} />} 43 </DataLoader> 44 ) 45 }, 46 { 47 title: 'LABELS', 48 view: Object.keys(app.metadata.labels || {}) 49 .map(label => `${label}=${app.metadata.labels[label]}`) 50 .join(' '), 51 edit: (formApi: FormApi) => <FormField formApi={formApi} field='metadata.labels' component={MapInputField} /> 52 }, 53 { 54 title: 'ANNOTATIONS', 55 view: ( 56 <Expandable height={48}> 57 {Object.keys(app.metadata.annotations || {}) 58 .map(annotation => `${annotation}=${app.metadata.annotations[annotation]}`) 59 .join(' ')} 60 </Expandable> 61 ), 62 edit: (formApi: FormApi) => <FormField formApi={formApi} field='metadata.annotations' component={MapInputField} /> 63 }, 64 { 65 title: 'CLUSTER', 66 view: <Cluster server={app.spec.destination.server} name={app.spec.destination.name} showUrl={true} />, 67 edit: (formApi: FormApi) => ( 68 <DataLoader load={() => services.clusters.list().then(clusters => clusters.sort())}> 69 {clusters => { 70 return ( 71 <div className='row'> 72 {(destFormat.toUpperCase() === 'URL' && ( 73 <div className='columns small-10'> 74 <FormField 75 formApi={formApi} 76 field='spec.destination.server' 77 componentProps={{items: clusters.map(cluster => cluster.server)}} 78 component={AutocompleteField} 79 /> 80 </div> 81 )) || ( 82 <div className='columns small-10'> 83 <FormField 84 formApi={formApi} 85 field='spec.destination.name' 86 componentProps={{items: clusters.map(cluster => cluster.name)}} 87 component={AutocompleteField} 88 /> 89 </div> 90 )} 91 <div className='columns small-2'> 92 <div> 93 <DropDownMenu 94 anchor={() => ( 95 <p> 96 {destFormat.toUpperCase()} <i className='fa fa-caret-down' /> 97 </p> 98 )} 99 items={['URL', 'NAME'].map((type: 'URL' | 'NAME') => ({ 100 title: type, 101 action: () => { 102 if (destFormat !== type) { 103 const updatedApp = formApi.getFormState().values as models.Application; 104 if (type === 'URL') { 105 updatedApp.spec.destination.server = ''; 106 delete updatedApp.spec.destination.name; 107 } else { 108 updatedApp.spec.destination.name = ''; 109 delete updatedApp.spec.destination.server; 110 } 111 formApi.setAllValues(updatedApp); 112 setDestFormat(type); 113 } 114 } 115 }))} 116 /> 117 </div> 118 </div> 119 </div> 120 ); 121 }} 122 </DataLoader> 123 ) 124 }, 125 { 126 title: 'NAMESPACE', 127 view: app.spec.destination.namespace, 128 edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.destination.namespace' component={Text} /> 129 }, 130 { 131 title: 'REPO URL', 132 view: <Repo url={app.spec.source.repoURL} />, 133 edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.source.repoURL' component={Text} /> 134 }, 135 ...(isHelm 136 ? [ 137 { 138 title: 'CHART', 139 view: ( 140 <span> 141 {app.spec.source.chart}:{app.spec.source.targetRevision} 142 </span> 143 ), 144 edit: (formApi: FormApi) => ( 145 <DataLoader 146 input={{repoURL: formApi.getFormState().values.spec.source.repoURL}} 147 load={src => services.repos.charts(src.repoURL).catch(() => new Array<models.HelmChart>())}> 148 {(charts: models.HelmChart[]) => ( 149 <div className='row'> 150 <div className='columns small-10'> 151 <FormField 152 formApi={formApi} 153 field='spec.source.chart' 154 component={AutocompleteField} 155 componentProps={{ 156 items: charts.map(chart => chart.name), 157 filterSuggestions: true 158 }} 159 /> 160 </div> 161 <DataLoader 162 input={{charts, chart: formApi.getFormState().values.spec.source.chart}} 163 load={async data => { 164 const chartInfo = data.charts.find(chart => chart.name === data.chart); 165 return (chartInfo && chartInfo.versions) || new Array<string>(); 166 }}> 167 {(versions: string[]) => ( 168 <div className='columns small-2'> 169 <FormField 170 formApi={formApi} 171 field='spec.source.targetRevision' 172 component={AutocompleteField} 173 componentProps={{ 174 items: versions 175 }} 176 /> 177 <RevisionHelpIcon type='helm' top='0' /> 178 </div> 179 )} 180 </DataLoader> 181 </div> 182 )} 183 </DataLoader> 184 ) 185 } 186 ] 187 : [ 188 { 189 title: 'TARGET REVISION', 190 view: <Revision repoUrl={app.spec.source.repoURL} revision={app.spec.source.targetRevision || 'HEAD'} />, 191 edit: (formApi: FormApi) => <RevisionFormField helpIconTop={'0'} hideLabel={true} formApi={formApi} repoURL={app.spec.source.repoURL} /> 192 }, 193 { 194 title: 'PATH', 195 view: app.spec.source.path, 196 edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.source.path' component={Text} /> 197 } 198 ]), 199 200 { 201 title: 'REVISION HISTORY LIMIT', 202 view: app.spec.revisionHistoryLimit, 203 edit: (formApi: FormApi) => ( 204 <div style={{position: 'relative'}}> 205 <FormField formApi={formApi} field='spec.revisionHistoryLimit' componentProps={{style: {paddingRight: '1em'}, placeholder: '10'}} component={NumberField} /> 206 <div style={{position: 'absolute', right: '0', top: '0'}}> 207 <HelpIcon 208 title='This limits this number of items kept in the apps revision history. 209 This should only be changed in exceptional circumstances. 210 Setting to zero will store no history. This will reduce storage used. 211 Increasing will increase the space used to store the history, so we do not recommend increasing it. 212 Default is 10.' 213 /> 214 </div> 215 </div> 216 ) 217 }, 218 { 219 title: 'SYNC OPTIONS', 220 view: ((app.spec.syncPolicy || {}).syncOptions || []).join(', '), 221 edit: (formApi: FormApi) => ( 222 <div> 223 <FormField formApi={formApi} field='spec.syncPolicy.syncOptions' component={ApplicationSyncOptionsField} /> 224 </div> 225 ) 226 }, 227 { 228 title: 'STATUS', 229 view: ( 230 <span> 231 <ComparisonStatusIcon status={app.status.sync.status} /> {app.status.sync.status} {syncStatusMessage(app)} 232 </span> 233 ) 234 }, 235 { 236 title: 'HEALTH', 237 view: ( 238 <span> 239 <HealthStatusIcon state={app.status.health} /> {app.status.health.status} 240 </span> 241 ) 242 } 243 ]; 244 245 const urls = app.status.summary.externalURLs || []; 246 if (urls.length > 0) { 247 attributes.push({ 248 title: 'URLs', 249 view: ( 250 <React.Fragment> 251 {urls 252 .map(item => item.split('|')) 253 .map((parts, i) => ( 254 <a key={i} href={parts.length > 1 ? parts[1] : parts[0]} target='__blank'> 255 {parts[0]} 256 </a> 257 ))} 258 </React.Fragment> 259 ) 260 }); 261 } 262 263 if ((app.status.summary.images || []).length) { 264 attributes.push({ 265 title: 'IMAGES', 266 view: ( 267 <div className='application-summary__labels'> 268 {(app.status.summary.images || []).sort().map(image => ( 269 <span className='application-summary__label' key={image}> 270 {image} 271 </span> 272 ))} 273 </div> 274 ) 275 }); 276 } 277 278 async function setAutoSync(ctx: {popup: PopupApi}, confirmationTitle: string, confirmationText: string, prune: boolean, selfHeal: boolean) { 279 const confirmed = await ctx.popup.confirm(confirmationTitle, confirmationText); 280 if (confirmed) { 281 try { 282 setChangeSync(true); 283 const updatedApp = JSON.parse(JSON.stringify(props.app)) as models.Application; 284 if (!updatedApp.spec.syncPolicy) { 285 updatedApp.spec.syncPolicy = {}; 286 } 287 updatedApp.spec.syncPolicy.automated = {prune, selfHeal}; 288 await props.updateApp(updatedApp); 289 } finally { 290 setChangeSync(false); 291 } 292 } 293 } 294 295 async function unsetAutoSync(ctx: {popup: PopupApi}) { 296 const confirmed = await ctx.popup.confirm('Disable Auto-Sync?', 'Are you sure you want to disable automated application synchronization'); 297 if (confirmed) { 298 try { 299 setChangeSync(true); 300 const updatedApp = JSON.parse(JSON.stringify(props.app)) as models.Application; 301 updatedApp.spec.syncPolicy.automated = null; 302 await props.updateApp(updatedApp); 303 } finally { 304 setChangeSync(false); 305 } 306 } 307 } 308 309 const items = app.spec.info || []; 310 const [adjustedCount, setAdjustedCount] = React.useState(0); 311 312 const added = new Array<{name: string; value: string; key: string}>(); 313 for (let i = 0; i < adjustedCount; i++) { 314 added.push({name: '', value: '', key: (items.length + i).toString()}); 315 } 316 for (let i = 0; i > adjustedCount; i--) { 317 items.pop(); 318 } 319 const allItems = items.concat(added); 320 const infoItems: EditablePanelItem[] = allItems 321 .map((info, i) => ({ 322 key: i.toString(), 323 title: info.name, 324 view: info.value.match(urlPattern) ? ( 325 <a href={info.value} target='__blank'> 326 {info.value} 327 </a> 328 ) : ( 329 info.value 330 ), 331 titleEdit: (formApi: FormApi) => ( 332 <React.Fragment> 333 {i > 0 && ( 334 <i 335 className='fa fa-sort-up application-summary__sort-icon' 336 onClick={() => { 337 formApi.setValue('spec.info', swap(formApi.getFormState().values.spec.info || [], i, i - 1)); 338 }} 339 /> 340 )} 341 <FormField formApi={formApi} field={`spec.info[${[i]}].name`} component={Text} componentProps={{style: {width: '99%'}}} /> 342 {i < allItems.length - 1 && ( 343 <i 344 className='fa fa-sort-down application-summary__sort-icon' 345 onClick={() => { 346 formApi.setValue('spec.info', swap(formApi.getFormState().values.spec.info || [], i, i + 1)); 347 }} 348 /> 349 )} 350 </React.Fragment> 351 ), 352 edit: (formApi: FormApi) => ( 353 <React.Fragment> 354 <FormField formApi={formApi} field={`spec.info[${[i]}].value`} component={Text} /> 355 <i 356 className='fa fa-times application-summary__remove-icon' 357 onClick={() => { 358 const values = (formApi.getFormState().values.spec.info || []) as Array<any>; 359 formApi.setValue('spec.info', [...values.slice(0, i), ...values.slice(i + 1, values.length)]); 360 setAdjustedCount(adjustedCount - 1); 361 }} 362 /> 363 </React.Fragment> 364 ) 365 })) 366 .concat({ 367 key: '-1', 368 title: '', 369 titleEdit: () => ( 370 <button 371 className='argo-button argo-button--base' 372 onClick={() => { 373 setAdjustedCount(adjustedCount + 1); 374 }}> 375 ADD NEW ITEM 376 </button> 377 ), 378 view: null as any, 379 edit: null 380 }); 381 382 return ( 383 <div className='application-summary'> 384 <EditablePanel 385 save={props.updateApp} 386 validate={input => ({ 387 'spec.project': !input.spec.project && 'Project name is required', 388 'spec.destination.server': !input.spec.destination.server && input.spec.destination.hasOwnProperty('server') && 'Cluster server is required', 389 'spec.destination.name': !input.spec.destination.name && input.spec.destination.hasOwnProperty('name') && 'Cluster name is required' 390 })} 391 values={app} 392 title={app.metadata.name.toLocaleUpperCase()} 393 items={attributes} 394 /> 395 <Consumer> 396 {ctx => ( 397 <div className='white-box'> 398 <div className='white-box__details'> 399 <p>Sync Policy</p> 400 <div className='row white-box__details-row'> 401 <div className='columns small-3'>{(app.spec.syncPolicy && app.spec.syncPolicy.automated && <span>Automated</span>) || <span>None</span>}</div> 402 <div className='columns small-9'> 403 {(app.spec.syncPolicy && app.spec.syncPolicy.automated && ( 404 <button className='argo-button argo-button--base' onClick={() => unsetAutoSync(ctx)}> 405 <Spinner show={changeSync} style={{marginRight: '5px'}} /> 406 Disable Auto-Sync 407 </button> 408 )) || ( 409 <button 410 className='argo-button argo-button--base' 411 onClick={() => 412 setAutoSync(ctx, 'Enable Auto-Sync?', 'Are you sure you want to enable automated application synchronization?', false, false) 413 }> 414 <Spinner show={changeSync} style={{marginRight: '5px'}} /> 415 Enable Auto-Sync 416 </button> 417 )} 418 </div> 419 </div> 420 421 {app.spec.syncPolicy && app.spec.syncPolicy.automated && ( 422 <React.Fragment> 423 <div className='row white-box__details-row'> 424 <div className='columns small-3'>Prune Resources</div> 425 <div className='columns small-9'> 426 {(app.spec.syncPolicy.automated.prune && ( 427 <button 428 className='argo-button argo-button--base' 429 onClick={() => 430 setAutoSync( 431 ctx, 432 'Disable Prune Resources?', 433 'Are you sure you want to disable resource pruning during automated application synchronization?', 434 false, 435 app.spec.syncPolicy.automated.selfHeal 436 ) 437 }> 438 Disable 439 </button> 440 )) || ( 441 <button 442 className='argo-button argo-button--base' 443 onClick={() => 444 setAutoSync( 445 ctx, 446 'Enable Prune Resources?', 447 'Are you sure you want to enable resource pruning during automated application synchronization?', 448 true, 449 app.spec.syncPolicy.automated.selfHeal 450 ) 451 }> 452 Enable 453 </button> 454 )} 455 </div> 456 </div> 457 <div className='row white-box__details-row'> 458 <div className='columns small-3'>Self Heal</div> 459 <div className='columns small-9'> 460 {(app.spec.syncPolicy.automated.selfHeal && ( 461 <button 462 className='argo-button argo-button--base' 463 onClick={() => 464 setAutoSync( 465 ctx, 466 'Disable Self Heal?', 467 'Are you sure you want to disable automated self healing?', 468 app.spec.syncPolicy.automated.prune, 469 false 470 ) 471 }> 472 Disable 473 </button> 474 )) || ( 475 <button 476 className='argo-button argo-button--base' 477 onClick={() => 478 setAutoSync( 479 ctx, 480 'Enable Self Heal?', 481 'Are you sure you want to enable automated self healing?', 482 app.spec.syncPolicy.automated.prune, 483 true 484 ) 485 }> 486 Enable 487 </button> 488 )} 489 </div> 490 </div> 491 </React.Fragment> 492 )} 493 </div> 494 </div> 495 )} 496 </Consumer> 497 <BadgePanel app={props.app.metadata.name} /> 498 <EditablePanel save={props.updateApp} values={app} title='Info' items={infoItems} onModeSwitch={() => setAdjustedCount(0)} /> 499 </div> 500 ); 501 };