github.com/argoproj/argo-cd/v2@v2.10.9/ui/src/app/applications/components/utils.tsx (about) 1 import {models, DataLoader, FormField, MenuItem, NotificationType, Tooltip} from 'argo-ui'; 2 import {ActionButton} from 'argo-ui/v2'; 3 import * as classNames from 'classnames'; 4 import * as React from 'react'; 5 import * as ReactForm from 'react-form'; 6 import {FormApi, Text} from 'react-form'; 7 import * as moment from 'moment'; 8 import {BehaviorSubject, combineLatest, concat, from, fromEvent, Observable, Observer, Subscription} from 'rxjs'; 9 import {debounceTime, map} from 'rxjs/operators'; 10 import {AppContext, Context, ContextApis} from '../../shared/context'; 11 import {ResourceTreeNode} from './application-resource-tree/application-resource-tree'; 12 13 import {CheckboxField, COLORS, ErrorNotification, Revision} from '../../shared/components'; 14 import * as appModels from '../../shared/models'; 15 import {services} from '../../shared/services'; 16 17 require('./utils.scss'); 18 19 export interface NodeId { 20 kind: string; 21 namespace: string; 22 name: string; 23 group: string; 24 createdAt?: models.Time; 25 } 26 27 type ActionMenuItem = MenuItem & {disabled?: boolean; tooltip?: string}; 28 29 export function nodeKey(node: NodeId) { 30 return [node.group, node.kind, node.namespace, node.name].join('/'); 31 } 32 33 export function createdOrNodeKey(node: NodeId) { 34 return node?.createdAt || nodeKey(node); 35 } 36 37 export function isSameNode(first: NodeId, second: NodeId) { 38 return nodeKey(first) === nodeKey(second); 39 } 40 41 export function helpTip(text: string) { 42 return ( 43 <Tooltip content={text}> 44 <span style={{fontSize: 'smaller'}}> 45 {' '} 46 <i className='fas fa-info-circle' /> 47 </span> 48 </Tooltip> 49 ); 50 } 51 export async function deleteApplication(appName: string, appNamespace: string, apis: ContextApis): Promise<boolean> { 52 let confirmed = false; 53 const propagationPolicies: {name: string; message: string}[] = [ 54 { 55 name: 'Foreground', 56 message: `Cascade delete the application's resources using foreground propagation policy` 57 }, 58 { 59 name: 'Background', 60 message: `Cascade delete the application's resources using background propagation policy` 61 }, 62 { 63 name: 'Non-cascading', 64 message: `Only delete the application, but do not cascade delete its resources` 65 } 66 ]; 67 await apis.popup.prompt( 68 'Delete application', 69 api => ( 70 <div> 71 <p> 72 Are you sure you want to delete the application <kbd>{appName}</kbd>? 73 </p> 74 <div className='argo-form-row'> 75 <FormField 76 label={`Please type '${appName}' to confirm the deletion of the resource`} 77 formApi={api} 78 field='applicationName' 79 qeId='name-field-delete-confirmation' 80 component={Text} 81 /> 82 </div> 83 <p>Select propagation policy for application deletion</p> 84 <div className='propagation-policy-list'> 85 {propagationPolicies.map(policy => { 86 return ( 87 <FormField 88 formApi={api} 89 key={policy.name} 90 field='propagationPolicy' 91 component={PropagationPolicyOption} 92 componentProps={{ 93 policy: policy.name, 94 message: policy.message 95 }} 96 /> 97 ); 98 })} 99 </div> 100 </div> 101 ), 102 { 103 validate: vals => ({ 104 applicationName: vals.applicationName !== appName && 'Enter the application name to confirm the deletion' 105 }), 106 submit: async (vals, _, close) => { 107 try { 108 await services.applications.delete(appName, appNamespace, vals.propagationPolicy); 109 confirmed = true; 110 close(); 111 } catch (e) { 112 apis.notifications.show({ 113 content: <ErrorNotification title='Unable to delete application' e={e} />, 114 type: NotificationType.Error 115 }); 116 } 117 } 118 }, 119 {name: 'argo-icon-warning', color: 'warning'}, 120 'yellow', 121 {propagationPolicy: 'foreground'} 122 ); 123 return confirmed; 124 } 125 126 export async function confirmSyncingAppOfApps(apps: appModels.Application[], apis: ContextApis, form: FormApi): Promise<boolean> { 127 let confirmed = false; 128 const appNames: string[] = apps.map(app => app.metadata.name); 129 const appNameList = appNames.join(', '); 130 await apis.popup.prompt( 131 'Warning: Synchronize App of Multiple Apps using replace?', 132 api => ( 133 <div> 134 <p> 135 Are you sure you want to sync the application '{appNameList}' which contain(s) multiple apps with 'replace' option? This action will delete and recreate all 136 apps linked to '{appNameList}'. 137 </p> 138 <div className='argo-form-row'> 139 <FormField 140 label={`Please type '${appNameList}' to confirm the Syncing of the resource`} 141 formApi={api} 142 field='applicationName' 143 qeId='name-field-delete-confirmation' 144 component={Text} 145 /> 146 </div> 147 </div> 148 ), 149 { 150 validate: vals => ({ 151 applicationName: vals.applicationName !== appNameList && 'Enter the application name(s) to confirm syncing' 152 }), 153 submit: async (_vals, _, close) => { 154 try { 155 await form.submitForm(null); 156 confirmed = true; 157 close(); 158 } catch (e) { 159 apis.notifications.show({ 160 content: <ErrorNotification title='Unable to sync application' e={e} />, 161 type: NotificationType.Error 162 }); 163 } 164 } 165 }, 166 {name: 'argo-icon-warning', color: 'warning'}, 167 'yellow' 168 ); 169 return confirmed; 170 } 171 172 const PropagationPolicyOption = ReactForm.FormField((props: {fieldApi: ReactForm.FieldApi; policy: string; message: string}) => { 173 const { 174 fieldApi: {setValue} 175 } = props; 176 return ( 177 <div className='propagation-policy-option'> 178 <input 179 className='radio-button' 180 key={props.policy} 181 type='radio' 182 name='propagation-policy' 183 value={props.policy} 184 id={props.policy} 185 defaultChecked={props.policy === 'Foreground'} 186 onChange={() => setValue(props.policy.toLowerCase())} 187 /> 188 <label htmlFor={props.policy}> 189 {props.policy} {helpTip(props.message)} 190 </label> 191 </div> 192 ); 193 }); 194 195 export const OperationPhaseIcon = ({app}: {app: appModels.Application}) => { 196 const operationState = getAppOperationState(app); 197 if (operationState === undefined) { 198 return <React.Fragment />; 199 } 200 let className = ''; 201 let color = ''; 202 switch (operationState.phase) { 203 case appModels.OperationPhases.Succeeded: 204 className = 'fa fa-check-circle'; 205 color = COLORS.operation.success; 206 break; 207 case appModels.OperationPhases.Error: 208 className = 'fa fa-times-circle'; 209 color = COLORS.operation.error; 210 break; 211 case appModels.OperationPhases.Failed: 212 className = 'fa fa-times-circle'; 213 color = COLORS.operation.failed; 214 break; 215 default: 216 className = 'fa fa-circle-notch fa-spin'; 217 color = COLORS.operation.running; 218 break; 219 } 220 return <i title={getOperationStateTitle(app)} qe-id='utils-operations-status-title' className={className} style={{color}} />; 221 }; 222 223 export const ComparisonStatusIcon = ({ 224 status, 225 resource, 226 label, 227 noSpin 228 }: { 229 status: appModels.SyncStatusCode; 230 resource?: {requiresPruning?: boolean}; 231 label?: boolean; 232 noSpin?: boolean; 233 }) => { 234 let className = 'fas fa-question-circle'; 235 let color = COLORS.sync.unknown; 236 let title: string = 'Unknown'; 237 238 switch (status) { 239 case appModels.SyncStatuses.Synced: 240 className = 'fa fa-check-circle'; 241 color = COLORS.sync.synced; 242 title = 'Synced'; 243 break; 244 case appModels.SyncStatuses.OutOfSync: 245 const requiresPruning = resource && resource.requiresPruning; 246 className = requiresPruning ? 'fa fa-trash' : 'fa fa-arrow-alt-circle-up'; 247 title = 'OutOfSync'; 248 if (requiresPruning) { 249 title = `${title} (This resource is not present in the application's source. It will be deleted from Kubernetes if the prune option is enabled during sync.)`; 250 } 251 color = COLORS.sync.out_of_sync; 252 break; 253 case appModels.SyncStatuses.Unknown: 254 className = `fa fa-circle-notch ${noSpin ? '' : 'fa-spin'}`; 255 break; 256 } 257 return ( 258 <React.Fragment> 259 <i qe-id='utils-sync-status-title' title={title} className={className} style={{color}} /> {label && title} 260 </React.Fragment> 261 ); 262 }; 263 264 export function showDeploy(resource: string, revision: string, apis: ContextApis) { 265 apis.navigation.goto('.', {deploy: resource, revision}, {replace: true}); 266 } 267 268 export function findChildPod(node: appModels.ResourceNode, tree: appModels.ApplicationTree): appModels.ResourceNode { 269 const key = nodeKey(node); 270 271 const allNodes = tree.nodes.concat(tree.orphanedNodes || []); 272 const nodeByKey = new Map<string, appModels.ResourceNode>(); 273 allNodes.forEach(item => nodeByKey.set(nodeKey(item), item)); 274 275 const pods = tree.nodes.concat(tree.orphanedNodes || []).filter(item => item.kind === 'Pod'); 276 return pods.find(pod => { 277 const items: Array<appModels.ResourceNode> = [pod]; 278 while (items.length > 0) { 279 const next = items.pop(); 280 const parentKeys = (next.parentRefs || []).map(nodeKey); 281 if (parentKeys.includes(key)) { 282 return true; 283 } 284 parentKeys.forEach(item => { 285 const parent = nodeByKey.get(item); 286 if (parent) { 287 items.push(parent); 288 } 289 }); 290 } 291 292 return false; 293 }); 294 } 295 296 export const deletePodAction = async (pod: appModels.Pod, appContext: AppContext, appName: string, appNamespace: string) => { 297 appContext.apis.popup.prompt( 298 'Delete pod', 299 () => ( 300 <div> 301 <p> 302 Are you sure you want to delete Pod <kbd>{pod.name}</kbd>? 303 </p> 304 <div className='argo-form-row' style={{paddingLeft: '30px'}}> 305 <CheckboxField id='force-delete-checkbox' field='force'> 306 <label htmlFor='force-delete-checkbox'>Force delete</label> 307 </CheckboxField> 308 </div> 309 </div> 310 ), 311 { 312 submit: async (vals, _, close) => { 313 try { 314 await services.applications.deleteResource(appName, appNamespace, pod, !!vals.force, false); 315 close(); 316 } catch (e) { 317 appContext.apis.notifications.show({ 318 content: <ErrorNotification title='Unable to delete resource' e={e} />, 319 type: NotificationType.Error 320 }); 321 } 322 } 323 } 324 ); 325 }; 326 327 export const deletePopup = async (ctx: ContextApis, resource: ResourceTreeNode, application: appModels.Application, appChanged?: BehaviorSubject<appModels.Application>) => { 328 function isTopLevelResource(res: ResourceTreeNode, app: appModels.Application): boolean { 329 const uniqRes = `/${res.namespace}/${res.group}/${res.kind}/${res.name}`; 330 return app.status.resources.some(resStatus => `/${resStatus.namespace}/${resStatus.group}/${resStatus.kind}/${resStatus.name}` === uniqRes); 331 } 332 333 const isManaged = isTopLevelResource(resource, application); 334 const deleteOptions = { 335 option: 'foreground' 336 }; 337 function handleStateChange(option: string) { 338 deleteOptions.option = option; 339 } 340 return ctx.popup.prompt( 341 'Delete resource', 342 api => ( 343 <div> 344 <p> 345 Are you sure you want to delete {resource.kind} <kbd>{resource.name}</kbd>? 346 </p> 347 {isManaged ? ( 348 <div className='argo-form-row'> 349 <FormField label={`Please type '${resource.name}' to confirm the deletion of the resource`} formApi={api} field='resourceName' component={Text} /> 350 </div> 351 ) : ( 352 '' 353 )} 354 <div className='argo-form-row'> 355 <input 356 type='radio' 357 name='deleteOptions' 358 value='foreground' 359 onChange={() => handleStateChange('foreground')} 360 defaultChecked={true} 361 style={{marginRight: '5px'}} 362 id='foreground-delete-radio' 363 /> 364 <label htmlFor='foreground-delete-radio' style={{paddingRight: '30px'}}> 365 Foreground Delete {helpTip('Deletes the resource and dependent resources using the cascading policy in the foreground')} 366 </label> 367 <input type='radio' name='deleteOptions' value='force' onChange={() => handleStateChange('force')} style={{marginRight: '5px'}} id='force-delete-radio' /> 368 <label htmlFor='force-delete-radio' style={{paddingRight: '30px'}}> 369 Background Delete {helpTip('Performs a forceful "background cascading deletion" of the resource and its dependent resources')} 370 </label> 371 <input type='radio' name='deleteOptions' value='orphan' onChange={() => handleStateChange('orphan')} style={{marginRight: '5px'}} id='cascade-delete-radio' /> 372 <label htmlFor='cascade-delete-radio'>Non-cascading (Orphan) Delete {helpTip('Deletes the resource and orphans the dependent resources')}</label> 373 </div> 374 </div> 375 ), 376 { 377 validate: vals => 378 isManaged && { 379 resourceName: vals.resourceName !== resource.name && 'Enter the resource name to confirm the deletion' 380 }, 381 submit: async (vals, _, close) => { 382 const force = deleteOptions.option === 'force'; 383 const orphan = deleteOptions.option === 'orphan'; 384 try { 385 await services.applications.deleteResource(application.metadata.name, application.metadata.namespace, resource, !!force, !!orphan); 386 if (appChanged) { 387 appChanged.next(await services.applications.get(application.metadata.name, application.metadata.namespace)); 388 } 389 close(); 390 } catch (e) { 391 ctx.notifications.show({ 392 content: <ErrorNotification title='Unable to delete resource' e={e} />, 393 type: NotificationType.Error 394 }); 395 } 396 } 397 }, 398 {name: 'argo-icon-warning', color: 'warning'}, 399 'yellow' 400 ); 401 }; 402 403 function getResourceActionsMenuItems(resource: ResourceTreeNode, metadata: models.ObjectMeta, apis: ContextApis): Promise<ActionMenuItem[]> { 404 return services.applications 405 .getResourceActions(metadata.name, metadata.namespace, resource) 406 .then(actions => { 407 return actions.map( 408 action => 409 ({ 410 title: action.displayName ?? action.name, 411 disabled: !!action.disabled, 412 iconClassName: action.iconClass, 413 action: async () => { 414 try { 415 const confirmed = await apis.popup.confirm(`Execute '${action.name}' action?`, `Are you sure you want to execute '${action.name}' action?`); 416 if (confirmed) { 417 await services.applications.runResourceAction(metadata.name, metadata.namespace, resource, action.name); 418 } 419 } catch (e) { 420 apis.notifications.show({ 421 content: <ErrorNotification title='Unable to execute resource action' e={e} />, 422 type: NotificationType.Error 423 }); 424 } 425 } 426 } as MenuItem) 427 ); 428 }) 429 .catch(() => [] as MenuItem[]); 430 } 431 432 function getActionItems( 433 resource: ResourceTreeNode, 434 application: appModels.Application, 435 tree: appModels.ApplicationTree, 436 apis: ContextApis, 437 appChanged: BehaviorSubject<appModels.Application>, 438 isQuickStart: boolean 439 ): Observable<ActionMenuItem[]> { 440 const isRoot = resource.root && nodeKey(resource.root) === nodeKey(resource); 441 const items: MenuItem[] = [ 442 ...((isRoot && [ 443 { 444 title: 'Sync', 445 iconClassName: 'fa fa-fw fa-sync', 446 action: () => showDeploy(nodeKey(resource), null, apis) 447 } 448 ]) || 449 []), 450 { 451 title: 'Delete', 452 iconClassName: 'fa fa-fw fa-times-circle', 453 action: async () => { 454 return deletePopup(apis, resource, application, appChanged); 455 } 456 } 457 ]; 458 if (!isQuickStart) { 459 items.unshift({ 460 title: 'Details', 461 iconClassName: 'fa fa-fw fa-info-circle', 462 action: () => apis.navigation.goto('.', {node: nodeKey(resource)}) 463 }); 464 } 465 466 if (findChildPod(resource, tree)) { 467 items.push({ 468 title: 'Logs', 469 iconClassName: 'fa fa-fw fa-align-left', 470 action: () => apis.navigation.goto('.', {node: nodeKey(resource), tab: 'logs'}, {replace: true}) 471 }); 472 } 473 474 if (isQuickStart) { 475 return from([items]); 476 } 477 478 const execAction = services.authService 479 .settings() 480 .then(async settings => { 481 const execAllowed = settings.execEnabled && (await services.accounts.canI('exec', 'create', application.spec.project + '/' + application.metadata.name)); 482 if (resource.kind === 'Pod' && execAllowed) { 483 return [ 484 { 485 title: 'Exec', 486 iconClassName: 'fa fa-fw fa-terminal', 487 action: async () => apis.navigation.goto('.', {node: nodeKey(resource), tab: 'exec'}, {replace: true}) 488 } as MenuItem 489 ]; 490 } 491 return [] as MenuItem[]; 492 }) 493 .catch(() => [] as MenuItem[]); 494 495 const resourceActions = getResourceActionsMenuItems(resource, application.metadata, apis); 496 497 const links = services.applications 498 .getResourceLinks(application.metadata.name, application.metadata.namespace, resource) 499 .then(data => { 500 return (data.items || []).map( 501 link => 502 ({ 503 title: link.title, 504 iconClassName: `fa fa-fw ${link.iconClass ? link.iconClass : 'fa-external-link'}`, 505 action: () => window.open(link.url, '_blank'), 506 tooltip: link.description 507 } as MenuItem) 508 ); 509 }) 510 .catch(() => [] as MenuItem[]); 511 512 return combineLatest( 513 from([items]), // this resolves immediately 514 concat([[] as MenuItem[]], resourceActions), // this resolves at first to [] and then whatever the API returns 515 concat([[] as MenuItem[]], execAction), // this resolves at first to [] and then whatever the API returns 516 concat([[] as MenuItem[]], links) // this resolves at first to [] and then whatever the API returns 517 ).pipe(map(res => ([] as MenuItem[]).concat(...res))); 518 } 519 520 export function renderResourceMenu( 521 resource: ResourceTreeNode, 522 application: appModels.Application, 523 tree: appModels.ApplicationTree, 524 apis: ContextApis, 525 appChanged: BehaviorSubject<appModels.Application>, 526 getApplicationActionMenu: () => any 527 ): React.ReactNode { 528 let menuItems: Observable<ActionMenuItem[]>; 529 530 if (isAppNode(resource) && resource.name === application.metadata.name) { 531 menuItems = from([getApplicationActionMenu()]); 532 } else { 533 menuItems = getActionItems(resource, application, tree, apis, appChanged, false); 534 } 535 return ( 536 <DataLoader load={() => menuItems}> 537 {items => ( 538 <ul> 539 {items.map((item, i) => ( 540 <li 541 className={classNames('application-details__action-menu', {disabled: item.disabled})} 542 key={i} 543 onClick={e => { 544 e.stopPropagation(); 545 if (!item.disabled) { 546 item.action(); 547 document.body.click(); 548 } 549 }}> 550 {item.tooltip ? ( 551 <Tooltip content={item.tooltip || ''}> 552 <div> 553 {item.iconClassName && <i className={item.iconClassName} />} {item.title} 554 </div> 555 </Tooltip> 556 ) : ( 557 <> 558 {item.iconClassName && <i className={item.iconClassName} />} {item.title} 559 </> 560 )} 561 </li> 562 ))} 563 </ul> 564 )} 565 </DataLoader> 566 ); 567 } 568 569 export function renderResourceActionMenu(resource: ResourceTreeNode, application: appModels.Application, apis: ContextApis): React.ReactNode { 570 const menuItems = getResourceActionsMenuItems(resource, application.metadata, apis); 571 572 return ( 573 <DataLoader load={() => menuItems}> 574 {items => ( 575 <ul> 576 {items.map((item, i) => ( 577 <li 578 className={classNames('application-details__action-menu', {disabled: item.disabled})} 579 key={i} 580 onClick={e => { 581 e.stopPropagation(); 582 if (!item.disabled) { 583 item.action(); 584 document.body.click(); 585 } 586 }}> 587 {item.iconClassName && <i className={item.iconClassName} />} {item.title} 588 </li> 589 ))} 590 </ul> 591 )} 592 </DataLoader> 593 ); 594 } 595 596 export function renderResourceButtons( 597 resource: ResourceTreeNode, 598 application: appModels.Application, 599 tree: appModels.ApplicationTree, 600 apis: ContextApis, 601 appChanged: BehaviorSubject<appModels.Application> 602 ): React.ReactNode { 603 let menuItems: Observable<ActionMenuItem[]>; 604 menuItems = getActionItems(resource, application, tree, apis, appChanged, true); 605 return ( 606 <DataLoader load={() => menuItems}> 607 {items => ( 608 <div className='pod-view__node__quick-start-actions'> 609 {items.map((item, i) => ( 610 <ActionButton 611 disabled={item.disabled} 612 key={i} 613 action={(e: React.MouseEvent) => { 614 e.stopPropagation(); 615 if (!item.disabled) { 616 item.action(); 617 document.body.click(); 618 } 619 }} 620 icon={item.iconClassName} 621 tooltip={ 622 item.title 623 .toString() 624 .charAt(0) 625 .toUpperCase() + item.title.toString().slice(1) 626 } 627 /> 628 ))} 629 </div> 630 )} 631 </DataLoader> 632 ); 633 } 634 635 export function syncStatusMessage(app: appModels.Application) { 636 const source = getAppDefaultSource(app); 637 const rev = app.status.sync.revision || source.targetRevision || 'HEAD'; 638 let message = source.targetRevision || 'HEAD'; 639 640 if (app.status.sync.revision) { 641 if (source.chart) { 642 message += ' (' + app.status.sync.revision + ')'; 643 } else if (app.status.sync.revision.length >= 7 && !app.status.sync.revision.startsWith(source.targetRevision)) { 644 message += ' (' + app.status.sync.revision.substr(0, 7) + ')'; 645 } 646 } 647 switch (app.status.sync.status) { 648 case appModels.SyncStatuses.Synced: 649 return ( 650 <span> 651 to{' '} 652 <Revision repoUrl={source.repoURL} revision={rev}> 653 {message} 654 </Revision>{' '} 655 </span> 656 ); 657 case appModels.SyncStatuses.OutOfSync: 658 return ( 659 <span> 660 from{' '} 661 <Revision repoUrl={source.repoURL} revision={rev}> 662 {message} 663 </Revision>{' '} 664 </span> 665 ); 666 default: 667 return <span>{message}</span>; 668 } 669 } 670 671 export const HealthStatusIcon = ({state, noSpin}: {state: appModels.HealthStatus; noSpin?: boolean}) => { 672 let color = COLORS.health.unknown; 673 let icon = 'fa-question-circle'; 674 675 switch (state.status) { 676 case appModels.HealthStatuses.Healthy: 677 color = COLORS.health.healthy; 678 icon = 'fa-heart'; 679 break; 680 case appModels.HealthStatuses.Suspended: 681 color = COLORS.health.suspended; 682 icon = 'fa-pause-circle'; 683 break; 684 case appModels.HealthStatuses.Degraded: 685 color = COLORS.health.degraded; 686 icon = 'fa-heart-broken'; 687 break; 688 case appModels.HealthStatuses.Progressing: 689 color = COLORS.health.progressing; 690 icon = `fa fa-circle-notch ${noSpin ? '' : 'fa-spin'}`; 691 break; 692 case appModels.HealthStatuses.Missing: 693 color = COLORS.health.missing; 694 icon = 'fa-ghost'; 695 break; 696 } 697 let title: string = state.status; 698 if (state.message) { 699 title = `${state.status}: ${state.message}`; 700 } 701 return <i qe-id='utils-health-status-title' title={title} className={'fa ' + icon} style={{color}} />; 702 }; 703 704 export const PodHealthIcon = ({state}: {state: appModels.HealthStatus}) => { 705 let icon = 'fa-question-circle'; 706 707 switch (state.status) { 708 case appModels.HealthStatuses.Healthy: 709 icon = 'fa-check'; 710 break; 711 case appModels.HealthStatuses.Suspended: 712 icon = 'fa-check'; 713 break; 714 case appModels.HealthStatuses.Degraded: 715 icon = 'fa-times'; 716 break; 717 case appModels.HealthStatuses.Progressing: 718 icon = 'fa fa-circle-notch fa-spin'; 719 break; 720 } 721 let title: string = state.status; 722 if (state.message) { 723 title = `${state.status}: ${state.message}`; 724 } 725 return <i qe-id='utils-health-status-title' title={title} className={'fa ' + icon} />; 726 }; 727 728 export const PodPhaseIcon = ({state}: {state: appModels.PodPhase}) => { 729 let className = ''; 730 switch (state) { 731 case appModels.PodPhase.PodSucceeded: 732 className = 'fa fa-check'; 733 break; 734 case appModels.PodPhase.PodRunning: 735 className = 'fa fa-circle-notch fa-spin'; 736 break; 737 case appModels.PodPhase.PodPending: 738 className = 'fa fa-circle-notch fa-spin'; 739 break; 740 case appModels.PodPhase.PodFailed: 741 className = 'fa fa-times'; 742 break; 743 default: 744 className = 'fa fa-question-circle'; 745 break; 746 } 747 return <i qe-id='utils-pod-phase-icon' className={className} />; 748 }; 749 750 export const ResourceResultIcon = ({resource}: {resource: appModels.ResourceResult}) => { 751 let color = COLORS.sync_result.unknown; 752 let icon = 'fas fa-question-circle'; 753 754 if (!resource.hookType && resource.status) { 755 switch (resource.status) { 756 case appModels.ResultCodes.Synced: 757 color = COLORS.sync_result.synced; 758 icon = 'fa-heart'; 759 break; 760 case appModels.ResultCodes.Pruned: 761 color = COLORS.sync_result.pruned; 762 icon = 'fa-heart'; 763 break; 764 case appModels.ResultCodes.SyncFailed: 765 color = COLORS.sync_result.failed; 766 icon = 'fa-heart-broken'; 767 break; 768 case appModels.ResultCodes.PruneSkipped: 769 icon = 'fa-heart'; 770 break; 771 } 772 let title: string = resource.message; 773 if (resource.message) { 774 title = `${resource.status}: ${resource.message}`; 775 } 776 return <i title={title} className={'fa ' + icon} style={{color}} />; 777 } 778 if (resource.hookType && resource.hookPhase) { 779 let className = ''; 780 switch (resource.hookPhase) { 781 case appModels.OperationPhases.Running: 782 color = COLORS.operation.running; 783 className = 'fa fa-circle-notch fa-spin'; 784 break; 785 case appModels.OperationPhases.Failed: 786 color = COLORS.operation.failed; 787 className = 'fa fa-heart-broken'; 788 break; 789 case appModels.OperationPhases.Error: 790 color = COLORS.operation.error; 791 className = 'fa fa-heart-broken'; 792 break; 793 case appModels.OperationPhases.Succeeded: 794 color = COLORS.operation.success; 795 className = 'fa fa-heart'; 796 break; 797 case appModels.OperationPhases.Terminating: 798 color = COLORS.operation.terminating; 799 className = 'fa fa-circle-notch fa-spin'; 800 break; 801 } 802 let title: string = resource.message; 803 if (resource.message) { 804 title = `${resource.hookPhase}: ${resource.message}`; 805 } 806 return <i title={title} className={className} style={{color}} />; 807 } 808 return null; 809 }; 810 811 export const getAppOperationState = (app: appModels.Application): appModels.OperationState => { 812 if (app.operation) { 813 return { 814 phase: appModels.OperationPhases.Running, 815 message: (app.status && app.status.operationState && app.status.operationState.message) || 'waiting to start', 816 startedAt: new Date().toISOString(), 817 operation: { 818 sync: {} 819 } 820 } as appModels.OperationState; 821 } else if (app.metadata.deletionTimestamp) { 822 return { 823 phase: appModels.OperationPhases.Running, 824 startedAt: app.metadata.deletionTimestamp 825 } as appModels.OperationState; 826 } else { 827 return app.status.operationState; 828 } 829 }; 830 831 export function getOperationType(application: appModels.Application) { 832 const operation = application.operation || (application.status && application.status.operationState && application.status.operationState.operation); 833 if (application.metadata.deletionTimestamp && !application.operation) { 834 return 'Delete'; 835 } 836 if (operation && operation.sync) { 837 return 'Sync'; 838 } 839 return 'Unknown'; 840 } 841 842 const getOperationStateTitle = (app: appModels.Application) => { 843 const appOperationState = getAppOperationState(app); 844 const operationType = getOperationType(app); 845 switch (operationType) { 846 case 'Delete': 847 return 'Deleting'; 848 case 'Sync': 849 switch (appOperationState.phase) { 850 case 'Running': 851 return 'Syncing'; 852 case 'Error': 853 return 'Sync error'; 854 case 'Failed': 855 return 'Sync failed'; 856 case 'Succeeded': 857 return 'Sync OK'; 858 case 'Terminating': 859 return 'Terminated'; 860 } 861 } 862 return 'Unknown'; 863 }; 864 865 export const OperationState = ({app, quiet}: {app: appModels.Application; quiet?: boolean}) => { 866 const appOperationState = getAppOperationState(app); 867 if (appOperationState === undefined) { 868 return <React.Fragment />; 869 } 870 if (quiet && [appModels.OperationPhases.Running, appModels.OperationPhases.Failed, appModels.OperationPhases.Error].indexOf(appOperationState.phase) === -1) { 871 return <React.Fragment />; 872 } 873 874 return ( 875 <React.Fragment> 876 <OperationPhaseIcon app={app} /> {getOperationStateTitle(app)} 877 </React.Fragment> 878 ); 879 }; 880 881 export function getPodStateReason(pod: appModels.State): {message: string; reason: string; netContainerStatuses: any[]} { 882 let reason = pod.status.phase; 883 let message = ''; 884 if (pod.status.reason) { 885 reason = pod.status.reason; 886 } 887 888 let initializing = false; 889 890 let netContainerStatuses = pod.status.initContainerStatuses || []; 891 netContainerStatuses = netContainerStatuses.concat(pod.status.containerStatuses || []); 892 893 for (const container of (pod.status.initContainerStatuses || []).slice().reverse()) { 894 if (container.state.terminated && container.state.terminated.exitCode === 0) { 895 continue; 896 } 897 898 if (container.state.terminated) { 899 if (container.state.terminated.reason) { 900 reason = `Init:ExitCode:${container.state.terminated.exitCode}`; 901 } else { 902 reason = `Init:${container.state.terminated.reason}`; 903 message = container.state.terminated.message; 904 } 905 } else if (container.state.waiting && container.state.waiting.reason && container.state.waiting.reason !== 'PodInitializing') { 906 reason = `Init:${container.state.waiting.reason}`; 907 message = `Init:${container.state.waiting.message}`; 908 } else { 909 reason = `Init: ${(pod.spec.initContainers || []).length})`; 910 } 911 initializing = true; 912 break; 913 } 914 915 if (!initializing) { 916 let hasRunning = false; 917 for (const container of pod.status.containerStatuses || []) { 918 if (container.state.waiting && container.state.waiting.reason) { 919 reason = container.state.waiting.reason; 920 message = container.state.waiting.message; 921 } else if (container.state.terminated && container.state.terminated.reason) { 922 reason = container.state.terminated.reason; 923 message = container.state.terminated.message; 924 } else if (container.state.terminated && !container.state.terminated.reason) { 925 if (container.state.terminated.signal !== 0) { 926 reason = `Signal:${container.state.terminated.signal}`; 927 message = ''; 928 } else { 929 reason = `ExitCode:${container.state.terminated.exitCode}`; 930 message = ''; 931 } 932 } else if (container.ready && container.state.running) { 933 hasRunning = true; 934 } 935 } 936 937 // change pod status back to 'Running' if there is at least one container still reporting as 'Running' status 938 if (reason === 'Completed' && hasRunning) { 939 reason = 'Running'; 940 message = ''; 941 } 942 } 943 944 if ((pod as any).metadata.deletionTimestamp && pod.status.reason === 'NodeLost') { 945 reason = 'Unknown'; 946 message = ''; 947 } else if ((pod as any).metadata.deletionTimestamp) { 948 reason = 'Terminating'; 949 message = ''; 950 } 951 952 return {reason, message, netContainerStatuses}; 953 } 954 955 export const getPodReadinessGatesState = (pod: appModels.State): {nonExistingConditions: string[]; notPassedConditions: string[]} => { 956 // if pod does not have readiness gates then return empty status 957 if (!pod.spec?.readinessGates?.length) { 958 return { 959 nonExistingConditions: [], 960 notPassedConditions: [] 961 }; 962 } 963 964 const existingConditions = new Map<string, boolean>(); 965 const podConditions = new Map<string, boolean>(); 966 967 const podStatusConditions = pod.status?.conditions || []; 968 969 for (const condition of podStatusConditions) { 970 existingConditions.set(condition.type, true); 971 // priority order of conditions 972 // eg. if there are multiple conditions set with same name then the one which comes first is evaluated 973 if (podConditions.has(condition.type)) { 974 continue; 975 } 976 977 if (condition.status === 'False') { 978 podConditions.set(condition.type, false); 979 } else if (condition.status === 'True') { 980 podConditions.set(condition.type, true); 981 } 982 } 983 984 const nonExistingConditions: string[] = []; 985 const failedConditions: string[] = []; 986 987 const readinessGates: appModels.ReadinessGate[] = pod.spec?.readinessGates || []; 988 989 for (const readinessGate of readinessGates) { 990 if (!existingConditions.has(readinessGate.conditionType)) { 991 nonExistingConditions.push(readinessGate.conditionType); 992 } else if (podConditions.get(readinessGate.conditionType) === false) { 993 failedConditions.push(readinessGate.conditionType); 994 } 995 } 996 997 return { 998 nonExistingConditions, 999 notPassedConditions: failedConditions 1000 }; 1001 }; 1002 1003 export function getConditionCategory(condition: appModels.ApplicationCondition): 'error' | 'warning' | 'info' { 1004 if (condition.type.endsWith('Error')) { 1005 return 'error'; 1006 } else if (condition.type.endsWith('Warning')) { 1007 return 'warning'; 1008 } else { 1009 return 'info'; 1010 } 1011 } 1012 1013 export function isAppNode(node: appModels.ResourceNode) { 1014 return node.kind === 'Application' && node.group === 'argoproj.io'; 1015 } 1016 1017 export function getAppOverridesCount(app: appModels.Application) { 1018 const source = getAppDefaultSource(app); 1019 if (source.kustomize && source.kustomize.images) { 1020 return source.kustomize.images.length; 1021 } 1022 if (source.helm && source.helm.parameters) { 1023 return source.helm.parameters.length; 1024 } 1025 return 0; 1026 } 1027 1028 // getAppDefaultSource gets the first app source from `sources` or, if that list is missing or empty, the `source` 1029 // field. 1030 export function getAppDefaultSource(app?: appModels.Application) { 1031 if (!app) { 1032 return null; 1033 } 1034 return app.spec.sources && app.spec.sources.length > 0 ? app.spec.sources[0] : app.spec.source; 1035 } 1036 1037 export function getAppSpecDefaultSource(spec: appModels.ApplicationSpec) { 1038 return spec.sources && spec.sources.length > 0 ? spec.sources[0] : spec.source; 1039 } 1040 1041 export function isAppRefreshing(app: appModels.Application) { 1042 return !!(app.metadata.annotations && app.metadata.annotations[appModels.AnnotationRefreshKey]); 1043 } 1044 1045 export function setAppRefreshing(app: appModels.Application) { 1046 if (!app.metadata.annotations) { 1047 app.metadata.annotations = {}; 1048 } 1049 if (!app.metadata.annotations[appModels.AnnotationRefreshKey]) { 1050 app.metadata.annotations[appModels.AnnotationRefreshKey] = 'refreshing'; 1051 } 1052 } 1053 1054 export function refreshLinkAttrs(app: appModels.Application) { 1055 return {disabled: isAppRefreshing(app)}; 1056 } 1057 1058 export const SyncWindowStatusIcon = ({state, window}: {state: appModels.SyncWindowsState; window: appModels.SyncWindow}) => { 1059 let className = ''; 1060 let color = ''; 1061 let current = ''; 1062 1063 if (state.windows === undefined) { 1064 current = 'Inactive'; 1065 } else { 1066 for (const w of state.windows) { 1067 if (w.kind === window.kind && w.schedule === window.schedule && w.duration === window.duration && w.timeZone === window.timeZone) { 1068 current = 'Active'; 1069 break; 1070 } else { 1071 current = 'Inactive'; 1072 } 1073 } 1074 } 1075 1076 switch (current + ':' + window.kind) { 1077 case 'Active:deny': 1078 case 'Inactive:allow': 1079 className = 'fa fa-stop-circle'; 1080 if (window.manualSync) { 1081 color = COLORS.sync_window.manual; 1082 } else { 1083 color = COLORS.sync_window.deny; 1084 } 1085 break; 1086 case 'Active:allow': 1087 case 'Inactive:deny': 1088 className = 'fa fa-check-circle'; 1089 color = COLORS.sync_window.allow; 1090 break; 1091 default: 1092 className = 'fas fa-question-circle'; 1093 color = COLORS.sync_window.unknown; 1094 current = 'Unknown'; 1095 break; 1096 } 1097 1098 return ( 1099 <React.Fragment> 1100 <i title={current} className={className} style={{color}} /> {current} 1101 </React.Fragment> 1102 ); 1103 }; 1104 1105 export const ApplicationSyncWindowStatusIcon = ({project, state}: {project: string; state: appModels.ApplicationSyncWindowState}) => { 1106 let className = ''; 1107 let color = ''; 1108 let deny = false; 1109 let allow = false; 1110 let inactiveAllow = false; 1111 if (state.assignedWindows !== undefined && state.assignedWindows.length > 0) { 1112 if (state.activeWindows !== undefined && state.activeWindows.length > 0) { 1113 for (const w of state.activeWindows) { 1114 if (w.kind === 'deny') { 1115 deny = true; 1116 } else if (w.kind === 'allow') { 1117 allow = true; 1118 } 1119 } 1120 } 1121 for (const a of state.assignedWindows) { 1122 if (a.kind === 'allow') { 1123 inactiveAllow = true; 1124 } 1125 } 1126 } else { 1127 allow = true; 1128 } 1129 1130 if (deny || (!deny && !allow && inactiveAllow)) { 1131 className = 'fa fa-stop-circle'; 1132 if (state.canSync) { 1133 color = COLORS.sync_window.manual; 1134 } else { 1135 color = COLORS.sync_window.deny; 1136 } 1137 } else { 1138 className = 'fa fa-check-circle'; 1139 color = COLORS.sync_window.allow; 1140 } 1141 1142 const ctx = React.useContext(Context); 1143 1144 return ( 1145 <a href={`${ctx.baseHref}settings/projects/${project}?tab=windows`} style={{color}}> 1146 <i className={className} style={{color}} /> SyncWindow 1147 </a> 1148 ); 1149 }; 1150 1151 /** 1152 * Automatically stops and restarts the given observable when page visibility changes. 1153 */ 1154 export function handlePageVisibility<T>(src: () => Observable<T>): Observable<T> { 1155 return new Observable<T>((observer: Observer<T>) => { 1156 let subscription: Subscription; 1157 const ensureUnsubscribed = () => { 1158 if (subscription) { 1159 subscription.unsubscribe(); 1160 subscription = null; 1161 } 1162 }; 1163 const start = () => { 1164 ensureUnsubscribed(); 1165 subscription = src().subscribe( 1166 (item: T) => observer.next(item), 1167 err => observer.error(err), 1168 () => observer.complete() 1169 ); 1170 }; 1171 1172 if (!document.hidden) { 1173 start(); 1174 } 1175 1176 const visibilityChangeSubscription = fromEvent(document, 'visibilitychange') 1177 // wait until user stop clicking back and forth to avoid restarting observable too often 1178 .pipe(debounceTime(500)) 1179 .subscribe(() => { 1180 if (document.hidden && subscription) { 1181 ensureUnsubscribed(); 1182 } else if (!document.hidden && !subscription) { 1183 start(); 1184 } 1185 }); 1186 1187 return () => { 1188 visibilityChangeSubscription.unsubscribe(); 1189 ensureUnsubscribed(); 1190 }; 1191 }); 1192 } 1193 1194 export function parseApiVersion(apiVersion: string): {group: string; version: string} { 1195 const parts = apiVersion.split('/'); 1196 if (parts.length > 1) { 1197 return {group: parts[0], version: parts[1]}; 1198 } 1199 return {version: parts[0], group: ''}; 1200 } 1201 1202 export function getContainerName(pod: any, containerIndex: number | null): string { 1203 if (containerIndex == null && pod.metadata?.annotations?.['kubectl.kubernetes.io/default-container']) { 1204 return pod.metadata?.annotations?.['kubectl.kubernetes.io/default-container']; 1205 } 1206 const containers = (pod.spec.containers || []).concat(pod.spec.initContainers || []); 1207 const container = containers[containerIndex || 0]; 1208 return container.name; 1209 } 1210 1211 export function isYoungerThanXMinutes(pod: any, x: number): boolean { 1212 const createdAt = moment(pod.createdAt, 'YYYY-MM-DDTHH:mm:ssZ'); 1213 const xMinutesAgo = moment().subtract(x, 'minutes'); 1214 return createdAt.isAfter(xMinutesAgo); 1215 } 1216 1217 export const BASE_COLORS = [ 1218 '#0DADEA', // blue 1219 '#DE7EAE', // pink 1220 '#FF9500', // orange 1221 '#4B0082', // purple 1222 '#F5d905', // yellow 1223 '#964B00' // brown 1224 ]; 1225 1226 export const urlPattern = new RegExp( 1227 new RegExp( 1228 // tslint:disable-next-line:max-line-length 1229 /^(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,})$/, 1230 'gi' 1231 ) 1232 ); 1233 1234 export function appQualifiedName(app: appModels.Application, nsEnabled: boolean): string { 1235 return (nsEnabled ? app.metadata.namespace + '/' : '') + app.metadata.name; 1236 } 1237 1238 export function appInstanceName(app: appModels.Application): string { 1239 return app.metadata.namespace + '_' + app.metadata.name; 1240 } 1241 1242 export function formatCreationTimestamp(creationTimestamp: string) { 1243 const createdAt = moment 1244 .utc(creationTimestamp) 1245 .local() 1246 .format('MM/DD/YYYY HH:mm:ss'); 1247 const fromNow = moment 1248 .utc(creationTimestamp) 1249 .local() 1250 .fromNow(); 1251 return ( 1252 <span> 1253 {createdAt} 1254 <i style={{padding: '2px'}} /> ({fromNow}) 1255 </span> 1256 ); 1257 } 1258 1259 export const selectPostfix = (arr: string[], singular: string, plural: string) => (arr.length > 1 ? plural : singular); 1260 1261 export function getUsrMsgKeyToDisplay(appName: string, msgKey: string, usrMessages: appModels.UserMessages[]) { 1262 const usrMsg = usrMessages?.find((msg: appModels.UserMessages) => msg.appName === appName && msg.msgKey === msgKey); 1263 if (usrMsg !== undefined) { 1264 return {...usrMsg, display: true}; 1265 } else { 1266 return {appName, msgKey, display: false, duration: 1} as appModels.UserMessages; 1267 } 1268 } 1269 1270 export const userMsgsList: {[key: string]: string} = { 1271 groupNodes: `Since the number of pods has surpassed the threshold pod count of 15, you will now be switched to the group node view. 1272 If you prefer the tree view, you can simply click on the Group Nodes toolbar button to deselect the current view.` 1273 };