github.com/argoproj/argo-cd/v3@v3.2.1/ui/src/app/applications/components/utils.tsx (about) 1 import {models, DataLoader, FormField, MenuItem, NotificationType, Tooltip, HelpIcon} 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 import {ApplicationSource} from '../../shared/models'; 17 18 require('./utils.scss'); 19 20 export interface NodeId { 21 kind: string; 22 namespace: string; 23 name: string; 24 group: string; 25 createdAt?: models.Time; 26 } 27 28 type ActionMenuItem = MenuItem & {disabled?: boolean; tooltip?: string}; 29 30 export function nodeKey(node: NodeId) { 31 return [node.group, node.kind, node.namespace, node.name].join('/'); 32 } 33 34 export function createdOrNodeKey(node: NodeId) { 35 return node?.createdAt || nodeKey(node); 36 } 37 38 export function isSameNode(first: NodeId, second: NodeId) { 39 return nodeKey(first) === nodeKey(second); 40 } 41 42 export function helpTip(text: string) { 43 return ( 44 <Tooltip content={text}> 45 <span style={{fontSize: 'smaller'}}> 46 {' '} 47 <i className='fas fa-info-circle' /> 48 </span> 49 </Tooltip> 50 ); 51 } 52 53 //CLassic Solid circle-notch icon 54 //<!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--> 55 //this will replace all <i> fa-spin </i> icons as they are currently misbehaving with no fix available. 56 57 export const SpinningIcon = ({color, qeId}: {color: string; qeId: string}) => { 58 return ( 59 <svg className='icon spin' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512' style={{color}} qe-id={qeId}> 60 <path 61 fill={color} 62 d='M222.7 32.1c5 16.9-4.6 34.8-21.5 39.8C121.8 95.6 64 169.1 64 256c0 106 86 192 192 192s192-86 192-192c0-86.9-57.8-160.4-137.1-184.1c-16.9-5-26.6-22.9-21.5-39.8s22.9-26.6 39.8-21.5C434.9 42.1 512 140 512 256c0 141.4-114.6 256-256 256S0 397.4 0 256C0 140 77.1 42.1 182.9 10.6c16.9-5 34.8 4.6 39.8 21.5z' 63 /> 64 </svg> 65 ); 66 }; 67 68 export async function deleteApplication(appName: string, appNamespace: string, apis: ContextApis, application?: appModels.Application): Promise<boolean> { 69 let confirmed = false; 70 71 // Use common child application detection logic if application object is provided 72 const isChildApp = application ? isChildApplication(application) : false; 73 const dialogTitle = isChildApp ? 'Delete child application' : 'Delete application'; 74 const appType = isChildApp ? 'child Application' : 'Application'; 75 const confirmLabel = isChildApp ? 'child application' : 'application'; 76 77 // Check if this is being called from resource tree context 78 const isFromResourceTree = application !== undefined; 79 80 const propagationPolicies: {name: string; message: string}[] = [ 81 { 82 name: 'Foreground', 83 message: `Cascade delete the application's resources using foreground propagation policy` 84 }, 85 { 86 name: 'Background', 87 message: `Cascade delete the application's resources using background propagation policy` 88 }, 89 { 90 name: 'Non-cascading', 91 message: `Only delete the application, but do not cascade delete its resources` 92 } 93 ]; 94 await apis.popup.prompt( 95 dialogTitle, 96 api => ( 97 <div> 98 <p> 99 Are you sure you want to delete the <strong>{appType}</strong> <kbd>{appName}</kbd>? 100 </p> 101 {isFromResourceTree && ( 102 <p> 103 <strong> 104 <i className='fa fa-warning delete-dialog-icon warning' /> Note: 105 </strong>{' '} 106 You are about to delete an Application from the resource tree. This uses the same deletion behavior as the Applications list page. 107 </p> 108 )} 109 <p> 110 Deleting the application in <kbd>foreground</kbd> or <kbd>background</kbd> mode will delete all the application's managed resources, which can be{' '} 111 <strong>dangerous</strong>. Be sure you understand the effects of deleting this resource before continuing. Consider asking someone to review the change first. 112 </p> 113 <div className='argo-form-row'> 114 <FormField 115 label={`Please type '${appName}' to confirm the deletion of the ${confirmLabel}`} 116 formApi={api} 117 field='applicationName' 118 qeId='name-field-delete-confirmation' 119 component={Text} 120 /> 121 </div> 122 <p>Select propagation policy for application deletion</p> 123 <div className='propagation-policy-list'> 124 {propagationPolicies.map(policy => { 125 return ( 126 <FormField 127 formApi={api} 128 key={policy.name} 129 field='propagationPolicy' 130 component={PropagationPolicyOption} 131 componentProps={{ 132 policy: policy.name, 133 message: policy.message 134 }} 135 /> 136 ); 137 })} 138 </div> 139 </div> 140 ), 141 { 142 validate: vals => ({ 143 applicationName: vals.applicationName !== appName && 'Enter the application name to confirm the deletion' 144 }), 145 submit: async (vals, _, close) => { 146 try { 147 await services.applications.delete(appName, appNamespace, vals.propagationPolicy); 148 confirmed = true; 149 close(); 150 } catch (e) { 151 apis.notifications.show({ 152 content: <ErrorNotification title='Unable to delete application' e={e} />, 153 type: NotificationType.Error 154 }); 155 } 156 } 157 }, 158 {name: 'argo-icon-warning', color: 'failed'}, 159 'red', 160 {propagationPolicy: 'foreground'} 161 ); 162 return confirmed; 163 } 164 165 export async function confirmSyncingAppOfApps(apps: appModels.Application[], apis: ContextApis, form: FormApi): Promise<boolean> { 166 let confirmed = false; 167 const appNames: string[] = apps.map(app => app.metadata.name); 168 const appNameList = appNames.join(', '); 169 await apis.popup.prompt( 170 'Warning: Synchronize App of Multiple Apps using replace?', 171 api => ( 172 <div> 173 <p> 174 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 175 apps linked to '{appNameList}'. 176 </p> 177 <div className='argo-form-row'> 178 <FormField 179 label={`Please type '${appNameList}' to confirm the Syncing of the resource`} 180 formApi={api} 181 field='applicationName' 182 qeId='name-field-delete-confirmation' 183 component={Text} 184 /> 185 </div> 186 </div> 187 ), 188 { 189 validate: vals => ({ 190 applicationName: vals.applicationName !== appNameList && 'Enter the application name(s) to confirm syncing' 191 }), 192 submit: async (_vals, _, close) => { 193 try { 194 await form.submitForm(null); 195 confirmed = true; 196 close(); 197 } catch (e) { 198 apis.notifications.show({ 199 content: <ErrorNotification title='Unable to sync application' e={e} />, 200 type: NotificationType.Error 201 }); 202 } 203 } 204 }, 205 {name: 'argo-icon-warning', color: 'warning'}, 206 'yellow' 207 ); 208 return confirmed; 209 } 210 211 const PropagationPolicyOption = ReactForm.FormField((props: {fieldApi: ReactForm.FieldApi; policy: string; message: string}) => { 212 const { 213 fieldApi: {setValue} 214 } = props; 215 return ( 216 <div className='propagation-policy-option'> 217 <input 218 className='radio-button' 219 key={props.policy} 220 type='radio' 221 name='propagation-policy' 222 value={props.policy} 223 id={props.policy} 224 defaultChecked={props.policy === 'Foreground'} 225 onChange={() => setValue(props.policy.toLowerCase())} 226 /> 227 <label htmlFor={props.policy}> 228 {props.policy} {helpTip(props.message)} 229 </label> 230 </div> 231 ); 232 }); 233 234 export const OperationPhaseIcon = ({app, isButton}: {app: appModels.Application; isButton?: boolean}) => { 235 const operationState = getAppOperationState(app); 236 if (operationState === undefined) { 237 return null; 238 } 239 let className = ''; 240 let color = ''; 241 switch (operationState.phase) { 242 case appModels.OperationPhases.Succeeded: 243 className = `fa fa-check-circle${isButton ? ' status-button' : ''}`; 244 color = COLORS.operation.success; 245 break; 246 case appModels.OperationPhases.Error: 247 className = `fa fa-times-circle${isButton ? ' status-button' : ''}`; 248 color = COLORS.operation.error; 249 break; 250 case appModels.OperationPhases.Failed: 251 className = `fa fa-times-circle${isButton ? ' status-button' : ''}`; 252 color = COLORS.operation.failed; 253 break; 254 default: 255 className = 'fa fa-circle-notch fa-spin'; 256 color = COLORS.operation.running; 257 break; 258 } 259 return className.includes('fa-spin') ? ( 260 <SpinningIcon color={color} qeId='utils-operations-status-title' /> 261 ) : ( 262 <i title={getOperationStateTitle(app)} qe-id='utils-operations-status-title' className={className} style={{color}} /> 263 ); 264 }; 265 266 export const HydrateOperationPhaseIcon = ({operationState, isButton}: {operationState?: appModels.HydrateOperation; isButton?: boolean}) => { 267 if (operationState === undefined) { 268 return null; 269 } 270 let className = ''; 271 let color = ''; 272 switch (operationState.phase) { 273 case appModels.HydrateOperationPhases.Hydrated: 274 className = `fa fa-check-circle${isButton ? ' status-button' : ''}`; 275 color = COLORS.operation.success; 276 break; 277 case appModels.HydrateOperationPhases.Failed: 278 className = `fa fa-times-circle${isButton ? ' status-button' : ''}`; 279 color = COLORS.operation.failed; 280 break; 281 default: 282 className = 'fa fa-circle-notch fa-spin'; 283 color = COLORS.operation.running; 284 break; 285 } 286 return className.includes('fa-spin') ? ( 287 <SpinningIcon color={color} qeId='utils-operations-status-title' /> 288 ) : ( 289 <i title={operationState.phase} qe-id='utils-operations-status-title' className={className} style={{color}} /> 290 ); 291 }; 292 293 export const ComparisonStatusIcon = ({ 294 status, 295 resource, 296 label, 297 noSpin, 298 isButton 299 }: { 300 status: appModels.SyncStatusCode; 301 resource?: {requiresPruning?: boolean}; 302 label?: boolean; 303 noSpin?: boolean; 304 isButton?: boolean; 305 }) => { 306 let className = 'fas fa-question-circle'; 307 let color = COLORS.sync.unknown; 308 let title: string = 'Unknown'; 309 switch (status) { 310 case appModels.SyncStatuses.Synced: 311 className = `fa fa-check-circle${isButton ? ' status-button' : ''}`; 312 color = COLORS.sync.synced; 313 title = 'Synced'; 314 break; 315 case appModels.SyncStatuses.OutOfSync: 316 // eslint-disable-next-line no-case-declarations 317 const requiresPruning = resource && resource.requiresPruning; 318 className = requiresPruning ? `fa fa-trash${isButton ? ' status-button' : ''}` : `fa fa-arrow-alt-circle-up${isButton ? ' status-button' : ''}`; 319 title = 'OutOfSync'; 320 if (requiresPruning) { 321 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.)`; 322 } 323 color = COLORS.sync.out_of_sync; 324 break; 325 case appModels.SyncStatuses.Unknown: 326 className = `fa fa-circle-notch ${noSpin ? '' : 'fa-spin'}${isButton ? ' status-button' : ''}`; 327 break; 328 } 329 return className.includes('fa-spin') ? ( 330 <SpinningIcon color={color} qeId='utils-sync-status-title' /> 331 ) : ( 332 <React.Fragment> 333 <i qe-id='utils-sync-status-title' title={title} className={className} style={{color}} /> {label && title} 334 </React.Fragment> 335 ); 336 }; 337 338 export function showDeploy(resource: string, revision: string, apis: ContextApis) { 339 apis.navigation.goto('.', {deploy: resource, revision}, {replace: true}); 340 } 341 342 export function findChildPod(node: appModels.ResourceNode, tree: appModels.ApplicationTree): appModels.ResourceNode { 343 const key = nodeKey(node); 344 345 const allNodes = tree.nodes.concat(tree.orphanedNodes || []); 346 const nodeByKey = new Map<string, appModels.ResourceNode>(); 347 allNodes.forEach(item => nodeByKey.set(nodeKey(item), item)); 348 349 const pods = tree.nodes.concat(tree.orphanedNodes || []).filter(item => item.kind === 'Pod'); 350 return pods.find(pod => { 351 const items: Array<appModels.ResourceNode> = [pod]; 352 while (items.length > 0) { 353 const next = items.pop(); 354 const parentKeys = (next.parentRefs || []).map(nodeKey); 355 if (parentKeys.includes(key)) { 356 return true; 357 } 358 parentKeys.forEach(item => { 359 const parent = nodeByKey.get(item); 360 if (parent) { 361 items.push(parent); 362 } 363 }); 364 } 365 366 return false; 367 }); 368 } 369 370 export function findChildResources(node: appModels.ResourceNode, tree: appModels.ApplicationTree): appModels.ResourceNode[] { 371 const key = nodeKey(node); 372 373 const children: appModels.ResourceNode[] = []; 374 tree.nodes.forEach(item => { 375 (item.parentRefs || []).forEach(parent => { 376 if (key === nodeKey(parent)) { 377 children.push(item); 378 } 379 }); 380 }); 381 382 return children; 383 } 384 385 const deletePodAction = async (ctx: ContextApis, pod: appModels.ResourceNode, app: appModels.Application) => { 386 ctx.popup.prompt( 387 'Delete pod', 388 () => ( 389 <div> 390 <p> 391 Are you sure you want to delete <strong>Pod</strong> <kbd>{pod.name}</kbd>? 392 <span style={{display: 'block', marginBottom: '10px'}} /> 393 Deleting resources can be <strong>dangerous</strong>. Be sure you understand the effects of deleting this resource before continuing. Consider asking someone to 394 review the change first. 395 </p> 396 <div className='argo-form-row' style={{paddingLeft: '30px'}}> 397 <CheckboxField id='force-delete-checkbox' field='force' /> 398 <label htmlFor='force-delete-checkbox'>Force delete</label> 399 <HelpIcon title='If checked, Argo will ignore any configured grace period and delete the resource immediately' /> 400 </div> 401 </div> 402 ), 403 { 404 submit: async (vals, _, close) => { 405 try { 406 await services.applications.deleteResource(app.metadata.name, app.metadata.namespace, pod, !!vals.force, false); 407 close(); 408 } catch (e) { 409 ctx.notifications.show({ 410 content: <ErrorNotification title='Unable to delete resource' e={e} />, 411 type: NotificationType.Error 412 }); 413 } 414 } 415 } 416 ); 417 }; 418 419 export const deleteSourceAction = (app: appModels.Application, source: appModels.ApplicationSource, appContext: AppContext) => { 420 appContext.apis.popup.prompt( 421 'Delete source', 422 () => ( 423 <div> 424 <p> 425 Are you sure you want to delete the source with URL: <kbd>{source.repoURL}</kbd> 426 {source.path ? ( 427 <> 428 {' '} 429 and path: <kbd>{source.path}</kbd>? 430 </> 431 ) : ( 432 <>?</> 433 )} 434 </p> 435 </div> 436 ), 437 { 438 submit: async (vals, _, close) => { 439 try { 440 const i = app.spec.sources.indexOf(source); 441 app.spec.sources.splice(i, 1); 442 await services.applications.update(app); 443 close(); 444 } catch (e) { 445 appContext.apis.notifications.show({ 446 content: <ErrorNotification title='Unable to delete source' e={e} />, 447 type: NotificationType.Error 448 }); 449 } 450 } 451 }, 452 {name: 'argo-icon-warning', color: 'warning'}, 453 'yellow' 454 ); 455 }; 456 457 // Detect if a resource is an Application 458 const isApplicationResource = (resource: ResourceTreeNode): boolean => { 459 return resource.kind === 'Application' && resource.group === 'argoproj.io'; 460 }; 461 462 // Detect if an application is a child application 463 const isChildApplication = (application: appModels.Application): boolean => { 464 const partOfLabel = application.metadata.labels?.['app.kubernetes.io/part-of']; 465 return partOfLabel && partOfLabel.trim() !== ''; 466 }; 467 468 export const deletePopup = async ( 469 ctx: ContextApis, 470 resource: ResourceTreeNode, 471 application: appModels.Application, 472 isManaged: boolean, 473 childResources: appModels.ResourceNode[], 474 appChanged?: BehaviorSubject<appModels.Application> 475 ) => { 476 // Detect if this is an Application resource 477 const isApplication = isApplicationResource(resource); 478 479 // Check if we're in a parent-child context (used for both Application and non-Application resources) 480 const isInParentContext = isChildApplication(application); 481 482 // For Application resources, use the deleteApplication function with resource tree context 483 if (isApplication) { 484 return deleteApplication(resource.name, resource.namespace || '', ctx, application); 485 } 486 487 const deleteOptions = { 488 option: 'foreground' 489 }; 490 function handleStateChange(option: string) { 491 deleteOptions.option = option; 492 } 493 494 if (resource.kind === 'Pod' && !isManaged) { 495 return deletePodAction(ctx, resource, application); 496 } 497 498 // Determine dialog title and add custom messaging 499 const dialogTitle = 'Delete resource'; 500 let customMessage: React.ReactNode = null; 501 502 if (isInParentContext) { 503 customMessage = ( 504 <div> 505 <p> 506 <strong> 507 <i className='fa fa-exclamation-triangle delete-dialog-icon info' /> Note: 508 </strong>{' '} 509 You are about to delete a resource from a parent application's resource tree. 510 </p> 511 </div> 512 ); 513 } 514 515 return ctx.popup.prompt( 516 dialogTitle, 517 api => ( 518 <div> 519 <p> 520 Are you sure you want to delete <strong>{resource.kind}</strong> <kbd>{resource.name}</kbd>? 521 </p> 522 {customMessage} 523 <p> 524 Deleting resources can be <strong>dangerous</strong>. Be sure you understand the effects of deleting this resource before continuing. Consider asking someone to 525 review the change first. 526 </p> 527 528 {(childResources || []).length > 0 ? ( 529 <React.Fragment> 530 <p>Dependent resources:</p> 531 <ul> 532 {childResources.slice(0, 4).map((child, i) => ( 533 <li key={i}> 534 <kbd>{[child.kind, child.name].join('/')}</kbd> 535 </li> 536 ))} 537 {childResources.length === 5 ? ( 538 <li key='4'> 539 <kbd>{[childResources[4].kind, childResources[4].name].join('/')}</kbd> 540 </li> 541 ) : ( 542 '' 543 )} 544 {childResources.length > 5 ? <li key='N'>and {childResources.slice(4).length} more.</li> : ''} 545 </ul> 546 </React.Fragment> 547 ) : ( 548 '' 549 )} 550 551 {isManaged ? ( 552 <div className='argo-form-row'> 553 <FormField label={`Please type '${resource.name}' to confirm the deletion of the resource`} formApi={api} field='resourceName' component={Text} /> 554 </div> 555 ) : ( 556 '' 557 )} 558 <div className='argo-form-row'> 559 <input 560 type='radio' 561 name='deleteOptions' 562 value='foreground' 563 onChange={() => handleStateChange('foreground')} 564 defaultChecked={true} 565 style={{marginRight: '5px'}} 566 id='foreground-delete-radio' 567 /> 568 <label htmlFor='foreground-delete-radio' style={{paddingRight: '30px'}}> 569 Foreground Delete {helpTip('Deletes the resource and dependent resources using the cascading policy in the foreground')} 570 </label> 571 <input type='radio' name='deleteOptions' value='force' onChange={() => handleStateChange('force')} style={{marginRight: '5px'}} id='force-delete-radio' /> 572 <label htmlFor='force-delete-radio' style={{paddingRight: '30px'}}> 573 Background Delete {helpTip('Performs a forceful "background cascading deletion" of the resource and its dependent resources')} 574 </label> 575 <input type='radio' name='deleteOptions' value='orphan' onChange={() => handleStateChange('orphan')} style={{marginRight: '5px'}} id='cascade-delete-radio' /> 576 <label htmlFor='cascade-delete-radio'>Non-cascading (Orphan) Delete {helpTip('Deletes the resource and orphans the dependent resources')}</label> 577 </div> 578 </div> 579 ), 580 { 581 validate: vals => 582 isManaged && { 583 resourceName: vals.resourceName !== resource.name && 'Enter the resource name to confirm the deletion' 584 }, 585 submit: async (vals, _, close) => { 586 const force = deleteOptions.option === 'force'; 587 const orphan = deleteOptions.option === 'orphan'; 588 try { 589 await services.applications.deleteResource(application.metadata.name, application.metadata.namespace, resource, !!force, !!orphan); 590 if (appChanged) { 591 appChanged.next(await services.applications.get(application.metadata.name, application.metadata.namespace)); 592 } 593 close(); 594 } catch (e) { 595 ctx.notifications.show({ 596 content: <ErrorNotification title='Unable to delete resource' e={e} />, 597 type: NotificationType.Error 598 }); 599 } 600 } 601 }, 602 {name: 'argo-icon-warning', color: 'warning'}, 603 'yellow' 604 ); 605 }; 606 607 export async function getResourceActionsMenuItems(resource: ResourceTreeNode, metadata: models.ObjectMeta, apis: ContextApis): Promise<ActionMenuItem[]> { 608 // Don't call API for missing resources 609 if (!resource.uid) { 610 return []; 611 } 612 613 return services.applications 614 .getResourceActions(metadata.name, metadata.namespace, resource) 615 .then(actions => { 616 return actions.map(action => ({ 617 title: action.displayName ?? action.name, 618 disabled: !!action.disabled, 619 iconClassName: action.iconClass, 620 action: async () => { 621 const confirmed = false; 622 const title = action.params ? `Enter input parameters for action: ${action.name}` : `Perform ${action.name} action?`; 623 await apis.popup.prompt( 624 title, 625 api => ( 626 <div> 627 {!action.params && ( 628 <div className='argo-form-row'> 629 <div> Are you sure you want to perform {action.name} action?</div> 630 </div> 631 )} 632 {action.params && 633 action.params.map((param, index) => ( 634 <div className='argo-form-row' key={index}> 635 <FormField label={param.name} field={param.name} formApi={api} component={Text} /> 636 </div> 637 ))} 638 </div> 639 ), 640 { 641 submit: async (vals, _, close) => { 642 try { 643 const resourceActionParameters = action.params 644 ? action.params.map(param => ({ 645 name: param.name, 646 value: vals[param.name] || param.default, 647 type: param.type, 648 default: param.default 649 })) 650 : []; 651 await services.applications.runResourceAction(metadata.name, metadata.namespace, resource, action.name, resourceActionParameters); 652 close(); 653 } catch (e) { 654 apis.notifications.show({ 655 content: <ErrorNotification title='Unable to execute resource action' e={e} />, 656 type: NotificationType.Error 657 }); 658 } 659 } 660 }, 661 null, 662 null, 663 action.params 664 ? action.params.reduce((acc, res) => { 665 acc[res.name] = res.default; 666 return acc; 667 }, {} as any) 668 : {} 669 ); 670 return confirmed; 671 } 672 })); 673 }) 674 .catch(() => [] as ActionMenuItem[]); 675 } 676 677 function getActionItems( 678 resource: ResourceTreeNode, 679 application: appModels.Application, 680 tree: appModels.ApplicationTree, 681 apis: ContextApis, 682 appChanged: BehaviorSubject<appModels.Application>, 683 isQuickStart: boolean 684 ): Observable<ActionMenuItem[]> { 685 function isTopLevelResource(res: ResourceTreeNode, app: appModels.Application): boolean { 686 const uniqRes = `/${res.namespace}/${res.group}/${res.kind}/${res.name}`; 687 return app.status.resources.some(resStatus => `/${resStatus.namespace}/${resStatus.group}/${resStatus.kind}/${resStatus.name}` === uniqRes); 688 } 689 690 const isPod = resource.kind === 'Pod'; 691 const isManaged = isTopLevelResource(resource, application); 692 const childResources = findChildResources(resource, tree); 693 694 const items: MenuItem[] = [ 695 ...((isManaged && [ 696 { 697 title: 'Sync', 698 iconClassName: 'fa fa-fw fa-sync', 699 action: () => showDeploy(nodeKey(resource), null, apis) 700 } 701 ]) || 702 []), 703 { 704 title: 'Delete', 705 iconClassName: 'fa fa-fw fa-times-circle', 706 action: async () => { 707 return deletePopup(apis, resource, application, isManaged, childResources, appChanged); 708 } 709 } 710 ]; 711 712 if (!isQuickStart) { 713 items.unshift({ 714 title: 'Details', 715 iconClassName: 'fa fa-fw fa-info-circle', 716 action: () => apis.navigation.goto('.', {node: nodeKey(resource)}) 717 }); 718 } 719 720 const logsAction = services.accounts 721 .canI('logs', 'get', application.spec.project + '/' + application.metadata.name) 722 .then(async allowed => { 723 if (allowed && (isPod || findChildPod(resource, tree))) { 724 return [ 725 { 726 title: 'Logs', 727 iconClassName: 'fa fa-fw fa-align-left', 728 action: () => apis.navigation.goto('.', {node: nodeKey(resource), tab: 'logs'}, {replace: true}) 729 } as MenuItem 730 ]; 731 } 732 return [] as MenuItem[]; 733 }) 734 .catch(() => [] as MenuItem[]); 735 736 if (isQuickStart) { 737 return combineLatest( 738 from([items]), // this resolves immediately 739 concat([[] as MenuItem[]], logsAction) // this resolves at first to [] and then whatever the API returns 740 ).pipe(map(res => ([] as MenuItem[]).concat(...res))); 741 } 742 743 const execAction = services.authService 744 .settings() 745 .then(async settings => { 746 const execAllowed = settings.execEnabled && (await services.accounts.canI('exec', 'create', application.spec.project + '/' + application.metadata.name)); 747 if (isPod && execAllowed) { 748 return [ 749 { 750 title: 'Exec', 751 iconClassName: 'fa fa-fw fa-terminal', 752 action: async () => apis.navigation.goto('.', {node: nodeKey(resource), tab: 'exec'}, {replace: true}) 753 } as MenuItem 754 ]; 755 } 756 return [] as MenuItem[]; 757 }) 758 .catch(() => [] as MenuItem[]); 759 760 const resourceActions = getResourceActionsMenuItems(resource, application.metadata, apis); 761 762 const links = !resource.uid 763 ? Promise.resolve([]) 764 : services.applications 765 .getResourceLinks(application.metadata.name, application.metadata.namespace, resource) 766 .then(data => { 767 return (data.items || []).map( 768 link => 769 ({ 770 title: link.title, 771 iconClassName: `fa fa-fw ${link.iconClass ? link.iconClass : 'fa-external-link'}`, 772 action: () => window.open(link.url, '_blank'), 773 tooltip: link.description 774 }) as MenuItem 775 ); 776 }) 777 .catch(() => [] as MenuItem[]); 778 779 return combineLatest( 780 from([items]), // this resolves immediately 781 concat([[] as MenuItem[]], logsAction), // this resolves at first to [] and then whatever the API returns 782 concat([[] as MenuItem[]], resourceActions), // this resolves at first to [] and then whatever the API returns 783 concat([[] as MenuItem[]], execAction), // this resolves at first to [] and then whatever the API returns 784 concat([[] as MenuItem[]], links) // this resolves at first to [] and then whatever the API returns 785 ).pipe(map(res => ([] as MenuItem[]).concat(...res))); 786 } 787 788 export function renderResourceMenu( 789 resource: ResourceTreeNode, 790 application: appModels.Application, 791 tree: appModels.ApplicationTree, 792 apis: ContextApis, 793 appChanged: BehaviorSubject<appModels.Application>, 794 getApplicationActionMenu: () => any 795 ): React.ReactNode { 796 let menuItems: Observable<ActionMenuItem[]>; 797 798 if (isAppNode(resource) && resource.name === application.metadata.name) { 799 menuItems = from([getApplicationActionMenu()]); 800 } else { 801 menuItems = getActionItems(resource, application, tree, apis, appChanged, false); 802 } 803 return ( 804 <DataLoader load={() => menuItems}> 805 {items => ( 806 <ul> 807 {items.map((item, i) => ( 808 <li 809 className={classNames('application-details__action-menu', {disabled: item.disabled})} 810 tabIndex={item.disabled ? undefined : 0} 811 key={i} 812 onClick={e => { 813 e.stopPropagation(); 814 if (!item.disabled) { 815 item.action(); 816 document.body.click(); 817 } 818 }} 819 onKeyDown={e => { 820 if (e.keyCode === 13 || e.key === 'Enter') { 821 e.stopPropagation(); 822 setTimeout(() => { 823 item.action(); 824 document.body.click(); 825 }); 826 } 827 }}> 828 {item.tooltip ? ( 829 <Tooltip content={item.tooltip || ''}> 830 <div> 831 {item.iconClassName && <i className={item.iconClassName} />} {item.title} 832 </div> 833 </Tooltip> 834 ) : ( 835 <> 836 {item.iconClassName && <i className={item.iconClassName} />} {item.title} 837 </> 838 )} 839 </li> 840 ))} 841 </ul> 842 )} 843 </DataLoader> 844 ); 845 } 846 847 export function renderResourceActionMenu(menuItems: ActionMenuItem[]): React.ReactNode { 848 return ( 849 <ul> 850 {menuItems.map((item, i) => ( 851 <li 852 className={classNames('application-details__action-menu', {disabled: item.disabled})} 853 key={i} 854 onClick={e => { 855 e.stopPropagation(); 856 if (!item.disabled) { 857 item.action(); 858 document.body.click(); 859 } 860 }}> 861 {item.iconClassName && <i className={item.iconClassName} />} {item.title} 862 </li> 863 ))} 864 </ul> 865 ); 866 } 867 868 export function renderResourceButtons( 869 resource: ResourceTreeNode, 870 application: appModels.Application, 871 tree: appModels.ApplicationTree, 872 apis: ContextApis, 873 appChanged: BehaviorSubject<appModels.Application> 874 ): React.ReactNode { 875 const menuItems: Observable<ActionMenuItem[]> = getActionItems(resource, application, tree, apis, appChanged, true); 876 return ( 877 <DataLoader load={() => menuItems}> 878 {items => ( 879 <div className='pod-view__node__quick-start-actions'> 880 {items.map((item, i) => ( 881 <ActionButton 882 disabled={item.disabled} 883 key={i} 884 action={(e: React.MouseEvent) => { 885 e.stopPropagation(); 886 if (!item.disabled) { 887 item.action(); 888 document.body.click(); 889 } 890 }} 891 icon={item.iconClassName} 892 tooltip={item.title.toString().charAt(0).toUpperCase() + item.title.toString().slice(1)} 893 /> 894 ))} 895 </div> 896 )} 897 </DataLoader> 898 ); 899 } 900 901 export function syncStatusMessage(app: appModels.Application) { 902 const source = getAppDefaultSource(app); 903 const revision = getAppDefaultSyncRevision(app); 904 const rev = app.status.sync.revision || (source ? source.targetRevision || 'HEAD' : 'Unknown'); 905 let message = source ? source?.targetRevision || 'HEAD' : 'Unknown'; 906 907 if (revision && source) { 908 if (source.chart) { 909 message += ' (' + revision + ')'; 910 } else if (revision.length >= 7 && !revision.startsWith(source.targetRevision)) { 911 if (source.repoURL.startsWith('oci://')) { 912 // Show "sha256: " plus the first 7 actual characters of the digest. 913 if (revision.startsWith('sha256:')) { 914 message += ' (' + revision.substring(0, 14) + ')'; 915 } else { 916 message += ' (' + revision.substring(0, 7) + ')'; 917 } 918 } else { 919 message += ' (' + revision.substring(0, 7) + ')'; 920 } 921 } 922 } 923 924 switch (app.status.sync.status) { 925 case appModels.SyncStatuses.Synced: 926 return ( 927 <span> 928 to{' '} 929 <Revision repoUrl={source.repoURL} revision={rev}> 930 {message} 931 </Revision> 932 {getAppDefaultSyncRevisionExtra(app)}{' '} 933 </span> 934 ); 935 case appModels.SyncStatuses.OutOfSync: 936 return ( 937 <span> 938 from{' '} 939 <Revision repoUrl={source.repoURL} revision={rev}> 940 {message} 941 </Revision> 942 {getAppDefaultSyncRevisionExtra(app)}{' '} 943 </span> 944 ); 945 default: 946 return <span>{message}</span>; 947 } 948 } 949 950 export function hydrationStatusMessage(app: appModels.Application) { 951 const drySource = app.status.sourceHydrator.currentOperation.sourceHydrator.drySource; 952 const dryCommit = app.status.sourceHydrator.currentOperation.drySHA; 953 const syncSource: ApplicationSource = { 954 repoURL: drySource.repoURL, 955 targetRevision: 956 app.status.sourceHydrator.currentOperation.sourceHydrator.hydrateTo?.targetBranch || app.status.sourceHydrator.currentOperation.sourceHydrator.syncSource.targetBranch, 957 path: app.status.sourceHydrator.currentOperation.sourceHydrator.syncSource.path 958 }; 959 const hydratedCommit = app.status.sourceHydrator.currentOperation.hydratedSHA || ''; 960 961 switch (app.status.sourceHydrator.currentOperation.phase) { 962 case appModels.HydrateOperationPhases.Hydrated: 963 return ( 964 <span> 965 from{' '} 966 <Revision repoUrl={drySource.repoURL} revision={dryCommit}> 967 {drySource.targetRevision + ' (' + dryCommit.substr(0, 7) + ')'} 968 </Revision> 969 <br /> 970 to{' '} 971 <Revision repoUrl={syncSource.repoURL} revision={hydratedCommit}> 972 {syncSource.targetRevision + ' (' + hydratedCommit.substr(0, 7) + ')'} 973 </Revision> 974 </span> 975 ); 976 case appModels.HydrateOperationPhases.Hydrating: 977 return ( 978 <span> 979 from{' '} 980 <Revision repoUrl={drySource.repoURL} revision={drySource.targetRevision}> 981 {drySource.targetRevision} 982 </Revision> 983 <br /> 984 to{' '} 985 <Revision repoUrl={syncSource.repoURL} revision={syncSource.targetRevision}> 986 {syncSource.targetRevision} 987 </Revision> 988 </span> 989 ); 990 case appModels.HydrateOperationPhases.Failed: 991 return ( 992 <span> 993 from{' '} 994 <Revision repoUrl={drySource.repoURL} revision={dryCommit || drySource.targetRevision}> 995 {drySource.targetRevision} 996 {dryCommit && ' (' + dryCommit.substr(0, 7) + ')'} 997 </Revision> 998 <br /> 999 to{' '} 1000 <Revision repoUrl={syncSource.repoURL} revision={syncSource.targetRevision}> 1001 {syncSource.targetRevision} 1002 </Revision> 1003 </span> 1004 ); 1005 default: 1006 return <span>{}</span>; 1007 } 1008 } 1009 1010 export const HealthStatusIcon = ({state, noSpin}: {state: appModels.HealthStatus; noSpin?: boolean}) => { 1011 let color = COLORS.health.unknown; 1012 let icon = 'fa-question-circle'; 1013 1014 switch (state.status) { 1015 case appModels.HealthStatuses.Healthy: 1016 color = COLORS.health.healthy; 1017 icon = 'fa-heart'; 1018 break; 1019 case appModels.HealthStatuses.Suspended: 1020 color = COLORS.health.suspended; 1021 icon = 'fa-pause-circle'; 1022 break; 1023 case appModels.HealthStatuses.Degraded: 1024 color = COLORS.health.degraded; 1025 icon = 'fa-heart-broken'; 1026 break; 1027 case appModels.HealthStatuses.Progressing: 1028 color = COLORS.health.progressing; 1029 icon = `fa fa-circle-notch ${noSpin ? '' : 'fa-spin'}`; 1030 break; 1031 case appModels.HealthStatuses.Missing: 1032 color = COLORS.health.missing; 1033 icon = 'fa-ghost'; 1034 break; 1035 } 1036 let title: string = state.status; 1037 if (state.message) { 1038 title = `${state.status}: ${state.message}`; 1039 } 1040 return icon.includes('fa-spin') ? ( 1041 <SpinningIcon color={color} qeId='utils-health-status-title' /> 1042 ) : ( 1043 <i qe-id='utils-health-status-title' title={title} className={'fa ' + icon + ' utils-health-status-icon'} style={{color}} /> 1044 ); 1045 }; 1046 1047 export const PodHealthIcon = ({state}: {state: appModels.HealthStatus}) => { 1048 let icon = 'fa-question-circle'; 1049 1050 switch (state.status) { 1051 case appModels.HealthStatuses.Healthy: 1052 icon = 'fa-check'; 1053 break; 1054 case appModels.HealthStatuses.Suspended: 1055 icon = 'fa-check'; 1056 break; 1057 case appModels.HealthStatuses.Degraded: 1058 icon = 'fa-times'; 1059 break; 1060 case appModels.HealthStatuses.Progressing: 1061 icon = 'fa fa-circle-notch fa-spin'; 1062 break; 1063 } 1064 let title: string = state.status; 1065 if (state.message) { 1066 title = `${state.status}: ${state.message}`; 1067 } 1068 return icon.includes('fa-spin') ? ( 1069 <SpinningIcon color={'white'} qeId='utils-health-status-title' /> 1070 ) : ( 1071 <i qe-id='utils-health-status-title' title={title} className={'fa ' + icon} /> 1072 ); 1073 }; 1074 1075 export const PodPhaseIcon = ({state}: {state: appModels.PodPhase}) => { 1076 let className = ''; 1077 switch (state) { 1078 case appModels.PodPhase.PodSucceeded: 1079 className = 'fa fa-check'; 1080 break; 1081 case appModels.PodPhase.PodRunning: 1082 className = 'fa fa-circle-notch fa-spin'; 1083 break; 1084 case appModels.PodPhase.PodPending: 1085 className = 'fa fa-circle-notch fa-spin'; 1086 break; 1087 case appModels.PodPhase.PodFailed: 1088 className = 'fa fa-times'; 1089 break; 1090 default: 1091 className = 'fa fa-question-circle'; 1092 break; 1093 } 1094 return className.includes('fa-spin') ? <SpinningIcon color={'white'} qeId='utils-pod-phase-icon' /> : <i qe-id='utils-pod-phase-icon' className={className} />; 1095 }; 1096 1097 export const ResourceResultIcon = ({resource}: {resource: appModels.ResourceResult}) => { 1098 let color = COLORS.sync_result.unknown; 1099 let icon = 'fas fa-question-circle'; 1100 1101 if (!resource.hookType && resource.status) { 1102 switch (resource.status) { 1103 case appModels.ResultCodes.Synced: 1104 color = COLORS.sync_result.synced; 1105 icon = 'fa-heart'; 1106 break; 1107 case appModels.ResultCodes.Pruned: 1108 color = COLORS.sync_result.pruned; 1109 icon = 'fa-trash'; 1110 break; 1111 case appModels.ResultCodes.SyncFailed: 1112 color = COLORS.sync_result.failed; 1113 icon = 'fa-heart-broken'; 1114 break; 1115 case appModels.ResultCodes.PruneSkipped: 1116 icon = 'fa-heart'; 1117 break; 1118 } 1119 let title: string = resource.message; 1120 if (resource.message) { 1121 title = `${resource.status}: ${resource.message}`; 1122 } 1123 return <i title={title} className={'fa ' + icon} style={{color}} />; 1124 } 1125 if (resource.hookType && resource.hookPhase) { 1126 let className = ''; 1127 switch (resource.hookPhase) { 1128 case appModels.OperationPhases.Running: 1129 color = COLORS.operation.running; 1130 className = 'fa fa-circle-notch fa-spin'; 1131 break; 1132 case appModels.OperationPhases.Failed: 1133 color = COLORS.operation.failed; 1134 className = 'fa fa-heart-broken'; 1135 break; 1136 case appModels.OperationPhases.Error: 1137 color = COLORS.operation.error; 1138 className = 'fa fa-heart-broken'; 1139 break; 1140 case appModels.OperationPhases.Succeeded: 1141 color = COLORS.operation.success; 1142 className = 'fa fa-heart'; 1143 break; 1144 case appModels.OperationPhases.Terminating: 1145 color = COLORS.operation.terminating; 1146 className = 'fa fa-circle-notch fa-spin'; 1147 break; 1148 } 1149 let title: string = resource.message; 1150 if (resource.message) { 1151 title = `${resource.hookPhase}: ${resource.message}`; 1152 } 1153 return className.includes('fa-spin') ? <SpinningIcon color={color} qeId='utils-resource-result-icon' /> : <i title={title} className={className} style={{color}} />; 1154 } 1155 return null; 1156 }; 1157 1158 export const getAppOperationState = (app: appModels.Application): appModels.OperationState => { 1159 if (app.operation) { 1160 return { 1161 phase: appModels.OperationPhases.Running, 1162 message: (app.status && app.status.operationState && app.status.operationState.message) || 'waiting to start', 1163 startedAt: new Date().toISOString(), 1164 operation: { 1165 sync: {} 1166 } 1167 } as appModels.OperationState; 1168 } else if (app.metadata.deletionTimestamp) { 1169 return { 1170 phase: appModels.OperationPhases.Running, 1171 startedAt: app.metadata.deletionTimestamp 1172 } as appModels.OperationState; 1173 } else { 1174 return app.status.operationState; 1175 } 1176 }; 1177 1178 export function getOperationType(application: appModels.Application) { 1179 const operation = application.operation || (application.status && application.status.operationState && application.status.operationState.operation); 1180 if (application.metadata.deletionTimestamp && !application.operation) { 1181 return 'Delete'; 1182 } 1183 if (operation && operation.sync) { 1184 return 'Sync'; 1185 } 1186 return 'Unknown'; 1187 } 1188 1189 const getOperationStateTitle = (app: appModels.Application) => { 1190 const appOperationState = getAppOperationState(app); 1191 const operationType = getOperationType(app); 1192 switch (operationType) { 1193 case 'Delete': 1194 return 'Deleting'; 1195 case 'Sync': 1196 switch (appOperationState.phase) { 1197 case 'Running': 1198 return 'Syncing'; 1199 case 'Error': 1200 return 'Sync error'; 1201 case 'Failed': 1202 return 'Sync failed'; 1203 case 'Succeeded': 1204 return 'Sync OK'; 1205 case 'Terminating': 1206 return 'Terminated'; 1207 } 1208 } 1209 return 'Unknown'; 1210 }; 1211 1212 export const OperationState = ({app, quiet, isButton}: {app: appModels.Application; quiet?: boolean; isButton?: boolean}) => { 1213 const appOperationState = getAppOperationState(app); 1214 if (appOperationState === undefined) { 1215 return null; 1216 } 1217 if (quiet && [appModels.OperationPhases.Running, appModels.OperationPhases.Failed, appModels.OperationPhases.Error].indexOf(appOperationState.phase) === -1) { 1218 return null; 1219 } 1220 1221 return ( 1222 <React.Fragment> 1223 <OperationPhaseIcon app={app} isButton={isButton} /> {getOperationStateTitle(app)} 1224 </React.Fragment> 1225 ); 1226 }; 1227 1228 function isPodInitializedConditionTrue(status: any): boolean { 1229 if (!status?.conditions) { 1230 return false; 1231 } 1232 1233 for (const condition of status.conditions) { 1234 if (condition.type !== 'Initialized') { 1235 continue; 1236 } 1237 return condition.status === 'True'; 1238 } 1239 1240 return false; 1241 } 1242 1243 // isPodPhaseTerminal returns true if the pod's phase is terminal. 1244 function isPodPhaseTerminal(phase: appModels.PodPhase): boolean { 1245 return phase === appModels.PodPhase.PodFailed || phase === appModels.PodPhase.PodSucceeded; 1246 } 1247 1248 export function getPodStateReason(pod: appModels.State): {message: string; reason: string; netContainerStatuses: any[]} { 1249 if (!pod.status) { 1250 return {reason: 'Unknown', message: '', netContainerStatuses: []}; 1251 } 1252 1253 const podPhase = pod.status.phase; 1254 let reason = podPhase; 1255 let message = ''; 1256 if (pod.status.reason) { 1257 reason = pod.status.reason; 1258 } 1259 1260 let netContainerStatuses = pod.status.initContainerStatuses || []; 1261 netContainerStatuses = netContainerStatuses.concat(pod.status.containerStatuses || []); 1262 1263 for (const condition of pod.status.conditions || []) { 1264 if (condition.type === 'PodScheduled' && condition.reason === 'SchedulingGated') { 1265 reason = 'SchedulingGated'; 1266 } 1267 } 1268 1269 const initContainers: Record<string, any> = {}; 1270 1271 for (const container of pod.spec.initContainers ?? []) { 1272 initContainers[container.name] = container; 1273 } 1274 1275 let initializing = false; 1276 const initContainerStatuses = pod.status.initContainerStatuses || []; 1277 for (let i = 0; i < initContainerStatuses.length; i++) { 1278 const container = initContainerStatuses[i]; 1279 if (container.state.terminated && container.state.terminated.exitCode === 0) { 1280 continue; 1281 } 1282 1283 if (container.started && initContainers[container.name].restartPolicy === 'Always') { 1284 continue; 1285 } 1286 1287 if (container.state.terminated) { 1288 if (container.state.terminated.reason) { 1289 reason = `Init:ExitCode:${container.state.terminated.exitCode}`; 1290 } else { 1291 reason = `Init:${container.state.terminated.reason}`; 1292 message = container.state.terminated.message; 1293 } 1294 } else if (container.state.waiting && container.state.waiting.reason && container.state.waiting.reason !== 'PodInitializing') { 1295 reason = `Init:${container.state.waiting.reason}`; 1296 message = `Init:${container.state.waiting.message}`; 1297 } else { 1298 reason = `Init:${i}/${(pod.spec.initContainers || []).length}`; 1299 } 1300 initializing = true; 1301 break; 1302 } 1303 1304 if (!initializing || isPodInitializedConditionTrue(pod.status)) { 1305 let hasRunning = false; 1306 for (const container of pod.status.containerStatuses || []) { 1307 if (container.state.waiting && container.state.waiting.reason) { 1308 reason = container.state.waiting.reason; 1309 message = container.state.waiting.message; 1310 } else if (container.state.terminated && container.state.terminated.reason) { 1311 reason = container.state.terminated.reason; 1312 message = container.state.terminated.message; 1313 } else if (container.state.terminated && !container.state.terminated.reason) { 1314 if (container.state.terminated.signal !== 0) { 1315 reason = `Signal:${container.state.terminated.signal}`; 1316 message = ''; 1317 } else { 1318 reason = `ExitCode:${container.state.terminated.exitCode}`; 1319 message = ''; 1320 } 1321 } else if (container.ready && container.state.running) { 1322 hasRunning = true; 1323 } 1324 } 1325 1326 // change pod status back to 'Running' if there is at least one container still reporting as 'Running' status 1327 if (reason === 'Completed' && hasRunning) { 1328 reason = 'Running'; 1329 message = ''; 1330 } 1331 } 1332 1333 if ((pod as any).metadata.deletionTimestamp && pod.status.reason === 'NodeLost') { 1334 reason = 'Unknown'; 1335 message = ''; 1336 } else if ((pod as any).metadata.deletionTimestamp && !isPodPhaseTerminal(podPhase)) { 1337 reason = 'Terminating'; 1338 message = ''; 1339 } 1340 1341 return {reason, message, netContainerStatuses}; 1342 } 1343 1344 export const getPodReadinessGatesState = (pod: appModels.State): {nonExistingConditions: string[]; notPassedConditions: string[]} => { 1345 // if pod does not have readiness gates then return empty status 1346 if (!pod.spec?.readinessGates?.length) { 1347 return { 1348 nonExistingConditions: [], 1349 notPassedConditions: [] 1350 }; 1351 } 1352 1353 const existingConditions = new Map<string, boolean>(); 1354 const podConditions = new Map<string, boolean>(); 1355 1356 const podStatusConditions = pod.status?.conditions || []; 1357 1358 for (const condition of podStatusConditions) { 1359 existingConditions.set(condition.type, true); 1360 // priority order of conditions 1361 // e.g. if there are multiple conditions set with same name then the one which comes first is evaluated 1362 if (podConditions.has(condition.type)) { 1363 continue; 1364 } 1365 1366 if (condition.status === 'False') { 1367 podConditions.set(condition.type, false); 1368 } else if (condition.status === 'True') { 1369 podConditions.set(condition.type, true); 1370 } 1371 } 1372 1373 const nonExistingConditions: string[] = []; 1374 const failedConditions: string[] = []; 1375 1376 const readinessGates: appModels.ReadinessGate[] = pod.spec?.readinessGates || []; 1377 1378 for (const readinessGate of readinessGates) { 1379 if (!existingConditions.has(readinessGate.conditionType)) { 1380 nonExistingConditions.push(readinessGate.conditionType); 1381 } else if (podConditions.get(readinessGate.conditionType) === false) { 1382 failedConditions.push(readinessGate.conditionType); 1383 } 1384 } 1385 1386 return { 1387 nonExistingConditions, 1388 notPassedConditions: failedConditions 1389 }; 1390 }; 1391 1392 export function getConditionCategory(condition: appModels.ApplicationCondition): 'error' | 'warning' | 'info' { 1393 if (condition.type.endsWith('Error')) { 1394 return 'error'; 1395 } else if (condition.type.endsWith('Warning')) { 1396 return 'warning'; 1397 } else { 1398 return 'info'; 1399 } 1400 } 1401 1402 export function isAppNode(node: appModels.ResourceNode) { 1403 return node.kind === 'Application' && node.group === 'argoproj.io'; 1404 } 1405 1406 export function getAppOverridesCount(app: appModels.Application) { 1407 const source = getAppDefaultSource(app); 1408 if (source?.kustomize?.images) { 1409 return source.kustomize.images.length; 1410 } 1411 if (source?.helm?.parameters) { 1412 return source.helm.parameters.length; 1413 } 1414 return 0; 1415 } 1416 1417 // getAppDefaultSource gets the first app source from `sources` or, if that list is missing or empty, the `source` 1418 // field. 1419 export function getAppDefaultSource(app?: appModels.Application) { 1420 if (!app) { 1421 return null; 1422 } 1423 return getAppSpecDefaultSource(app.spec); 1424 } 1425 1426 // getAppDefaultSyncRevision gets the first app revisions from `status.sync.revisions` or, if that list is missing or empty, the `revision` 1427 // field. 1428 export function getAppDefaultSyncRevision(app?: appModels.Application) { 1429 if (!app || !app.status || !app.status.sync) { 1430 return ''; 1431 } 1432 return app.status.sync.revisions && app.status.sync.revisions.length > 0 ? app.status.sync.revisions[0] : app.status.sync.revision; 1433 } 1434 1435 // getAppDefaultOperationSyncRevision gets the first app revisions from `status.operationState.syncResult.revisions` or, if that list is missing or empty, the `revision` 1436 // field. 1437 export function getAppDefaultOperationSyncRevision(app?: appModels.Application) { 1438 if (!app || !app.status || !app.status.operationState || !app.status.operationState.syncResult) { 1439 return ''; 1440 } 1441 return app.status.operationState.syncResult.revisions && app.status.operationState.syncResult.revisions.length > 0 1442 ? app.status.operationState.syncResult.revisions[0] 1443 : app.status.operationState.syncResult.revision; 1444 } 1445 1446 // getAppCurrentVersion gets the first app revisions from `status.sync.revisions` or, if that list is missing or empty, the `revision` 1447 // field. 1448 export function getAppCurrentVersion(app?: appModels.Application): number | null { 1449 if (!app || !app.status || !app.status.history || app.status.history.length === 0) { 1450 return null; 1451 } 1452 return app.status.history[app.status.history.length - 1].id; 1453 } 1454 1455 // getAppDefaultSyncRevisionExtra gets the extra message with others revision count 1456 export function getAppDefaultSyncRevisionExtra(app?: appModels.Application) { 1457 if (!app || !app.status || !app.status.sync) { 1458 return ''; 1459 } 1460 1461 if (app.status.sync.revisions && app.status.sync.revisions.length > 0) { 1462 return ` and (${app.status.sync.revisions.length - 1}) more`; 1463 } 1464 1465 return ''; 1466 } 1467 1468 // getAppDefaultOperationSyncRevisionExtra gets the first app revisions from `status.operationState.syncResult.revisions` or, if that list is missing or empty, the `revision` 1469 // field. 1470 export function getAppDefaultOperationSyncRevisionExtra(app?: appModels.Application) { 1471 if (!app || !app.status || !app.status.operationState || !app.status.operationState.syncResult || !app.status.operationState.syncResult.revisions) { 1472 return ''; 1473 } 1474 1475 if (app.status.operationState.syncResult.revisions.length > 0) { 1476 return ` and (${app.status.operationState.syncResult.revisions.length - 1}) more`; 1477 } 1478 return ''; 1479 } 1480 1481 export function getAppSpecDefaultSource(spec: appModels.ApplicationSpec) { 1482 if (spec.sourceHydrator) { 1483 return { 1484 repoURL: spec.sourceHydrator.drySource.repoURL, 1485 targetRevision: spec.sourceHydrator.syncSource.targetBranch, 1486 path: spec.sourceHydrator.syncSource.path 1487 }; 1488 } 1489 return spec.sources && spec.sources.length > 0 ? spec.sources[0] : spec.source; 1490 } 1491 1492 export function isAppRefreshing(app: appModels.Application) { 1493 return !!(app.metadata.annotations && app.metadata.annotations[appModels.AnnotationRefreshKey]); 1494 } 1495 1496 export function setAppRefreshing(app: appModels.Application) { 1497 if (!app.metadata.annotations) { 1498 app.metadata.annotations = {}; 1499 } 1500 if (!app.metadata.annotations[appModels.AnnotationRefreshKey]) { 1501 app.metadata.annotations[appModels.AnnotationRefreshKey] = 'refreshing'; 1502 } 1503 } 1504 1505 export function refreshLinkAttrs(app: appModels.Application) { 1506 return {disabled: isAppRefreshing(app)}; 1507 } 1508 1509 export const SyncWindowStatusIcon = ({state, window}: {state: appModels.SyncWindowsState; window: appModels.SyncWindow}) => { 1510 let className = ''; 1511 let color = ''; 1512 let current = ''; 1513 1514 if (state.windows === undefined) { 1515 current = 'Inactive'; 1516 } else { 1517 for (const w of state.windows) { 1518 if (w.kind === window.kind && w.schedule === window.schedule && w.duration === window.duration && w.timeZone === window.timeZone) { 1519 current = 'Active'; 1520 break; 1521 } else { 1522 current = 'Inactive'; 1523 } 1524 } 1525 } 1526 1527 switch (current + ':' + window.kind) { 1528 case 'Active:deny': 1529 case 'Inactive:allow': 1530 className = 'fa fa-stop-circle'; 1531 if (window.manualSync) { 1532 color = COLORS.sync_window.manual; 1533 } else { 1534 color = COLORS.sync_window.deny; 1535 } 1536 break; 1537 case 'Active:allow': 1538 case 'Inactive:deny': 1539 className = 'fa fa-check-circle'; 1540 color = COLORS.sync_window.allow; 1541 break; 1542 default: 1543 className = 'fas fa-question-circle'; 1544 color = COLORS.sync_window.unknown; 1545 current = 'Unknown'; 1546 break; 1547 } 1548 1549 return ( 1550 <React.Fragment> 1551 <i title={current} className={className} style={{color}} /> {current} 1552 </React.Fragment> 1553 ); 1554 }; 1555 1556 export const ApplicationSyncWindowStatusIcon = ({project, state}: {project: string; state?: appModels.ApplicationSyncWindowState}) => { 1557 let className = ''; 1558 let color = ''; 1559 let deny = false; 1560 let allow = false; 1561 let inactiveAllow = false; 1562 if (state?.assignedWindows !== undefined && state?.assignedWindows.length > 0) { 1563 if (state.activeWindows !== undefined && state.activeWindows.length > 0) { 1564 for (const w of state.activeWindows) { 1565 if (w.kind === 'deny') { 1566 deny = true; 1567 } else if (w.kind === 'allow') { 1568 allow = true; 1569 } 1570 } 1571 } 1572 for (const a of state.assignedWindows) { 1573 if (a.kind === 'allow') { 1574 inactiveAllow = true; 1575 } 1576 } 1577 } else { 1578 allow = true; 1579 } 1580 1581 if (deny || (!deny && !allow && inactiveAllow)) { 1582 className = 'fa fa-stop-circle'; 1583 if (state.canSync) { 1584 color = COLORS.sync_window.manual; 1585 } else { 1586 color = COLORS.sync_window.deny; 1587 } 1588 } else { 1589 className = 'fa fa-check-circle'; 1590 color = COLORS.sync_window.allow; 1591 } 1592 1593 const ctx = React.useContext(Context); 1594 1595 return ( 1596 <a href={`${ctx.baseHref}settings/projects/${project}?tab=windows`} style={{color}}> 1597 <i className={className} style={{color}} /> SyncWindow 1598 </a> 1599 ); 1600 }; 1601 1602 /** 1603 * Automatically stops and restarts the given observable when page visibility changes. 1604 */ 1605 export function handlePageVisibility<T>(src: () => Observable<T>): Observable<T> { 1606 return new Observable<T>((observer: Observer<T>) => { 1607 let subscription: Subscription; 1608 const ensureUnsubscribed = () => { 1609 if (subscription) { 1610 subscription.unsubscribe(); 1611 subscription = null; 1612 } 1613 }; 1614 const start = () => { 1615 ensureUnsubscribed(); 1616 subscription = src().subscribe( 1617 (item: T) => observer.next(item), 1618 err => observer.error(err), 1619 () => observer.complete() 1620 ); 1621 }; 1622 1623 if (!document.hidden) { 1624 start(); 1625 } 1626 1627 const visibilityChangeSubscription = fromEvent(document, 'visibilitychange') 1628 // wait until user stop clicking back and forth to avoid restarting observable too often 1629 .pipe(debounceTime(500)) 1630 .subscribe(() => { 1631 if (document.hidden && subscription) { 1632 ensureUnsubscribed(); 1633 } else if (!document.hidden && !subscription) { 1634 start(); 1635 } 1636 }); 1637 1638 return () => { 1639 visibilityChangeSubscription.unsubscribe(); 1640 ensureUnsubscribed(); 1641 }; 1642 }); 1643 } 1644 1645 export function parseApiVersion(apiVersion: string): {group: string; version: string} { 1646 const parts = apiVersion.split('/'); 1647 if (parts.length > 1) { 1648 return {group: parts[0], version: parts[1]}; 1649 } 1650 return {version: parts[0], group: ''}; 1651 } 1652 1653 export function getContainerName(pod: any, containerIndex: number | null): string { 1654 if (containerIndex == null && pod.metadata?.annotations?.['kubectl.kubernetes.io/default-container']) { 1655 return pod.metadata?.annotations?.['kubectl.kubernetes.io/default-container']; 1656 } 1657 const containers = (pod.spec.containers || []).concat(pod.spec.initContainers || []); 1658 const container = containers[containerIndex || 0]; 1659 return container.name; 1660 } 1661 1662 export function isYoungerThanXMinutes(pod: any, x: number): boolean { 1663 const createdAt = moment(pod.createdAt, 'YYYY-MM-DDTHH:mm:ssZ'); 1664 const xMinutesAgo = moment().subtract(x, 'minutes'); 1665 return createdAt.isAfter(xMinutesAgo); 1666 } 1667 1668 export const BASE_COLORS = [ 1669 '#0DADEA', // blue 1670 '#DE7EAE', // pink 1671 '#FF9500', // orange 1672 '#4B0082', // purple 1673 '#F5d905', // yellow 1674 '#964B00' // brown 1675 ]; 1676 1677 export const urlPattern = new RegExp( 1678 new RegExp( 1679 // tslint:disable-next-line:max-line-length 1680 /^(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,})$/, 1681 'gi' 1682 ) 1683 ); 1684 1685 export function appQualifiedName(app: appModels.Application, nsEnabled: boolean): string { 1686 return (nsEnabled ? app.metadata.namespace + '/' : '') + app.metadata.name; 1687 } 1688 1689 export function appInstanceName(app: appModels.Application): string { 1690 return app.metadata.namespace + '_' + app.metadata.name; 1691 } 1692 1693 export function formatCreationTimestamp(creationTimestamp: string) { 1694 const createdAt = moment.utc(creationTimestamp).local().format('MM/DD/YYYY HH:mm:ss'); 1695 const fromNow = moment.utc(creationTimestamp).local().fromNow(); 1696 return ( 1697 <span> 1698 {createdAt} 1699 <i style={{padding: '2px'}} /> ({fromNow}) 1700 </span> 1701 ); 1702 } 1703 1704 /* 1705 * formatStatefulSetChange reformats a single line describing changes to immutable fields in a StatefulSet. 1706 * It extracts the field name and its "from" and "to" values for better readability. 1707 */ 1708 function formatStatefulSetChange(line: string): string { 1709 if (line.startsWith('-')) { 1710 // Remove leading "- " from the line and split into field and changes 1711 const [field, changes] = line.substring(2).split(':'); 1712 if (changes) { 1713 // Split "from: X to: Y" into separate lines with aligned values 1714 const [from, to] = changes.split('to:').map(s => s.trim()); 1715 return ` - ${field}:\n from: ${from.replace('from:', '').trim()}\n to: ${to}`; 1716 } 1717 } 1718 return line; 1719 } 1720 1721 export function formatOperationMessage(message: string): string { 1722 if (!message) { 1723 return message; 1724 } 1725 1726 // Format immutable fields error message 1727 if (message.includes('attempting to change immutable fields:')) { 1728 const [header, ...details] = message.split('\n'); 1729 const formattedDetails = details 1730 // Remove empty lines 1731 .filter(line => line.trim()) 1732 // Use helper function 1733 .map(formatStatefulSetChange) 1734 .join('\n'); 1735 1736 return `${header}\n${formattedDetails}`; 1737 } 1738 1739 return message; 1740 } 1741 1742 export const selectPostfix = (arr: string[], singular: string, plural: string) => (arr.length > 1 ? plural : singular); 1743 1744 export function getUsrMsgKeyToDisplay(appName: string, msgKey: string, usrMessages: appModels.UserMessages[]) { 1745 const usrMsg = usrMessages?.find((msg: appModels.UserMessages) => msg.appName === appName && msg.msgKey === msgKey); 1746 if (usrMsg !== undefined) { 1747 return {...usrMsg, display: true}; 1748 } else { 1749 return {appName, msgKey, display: false, duration: 1} as appModels.UserMessages; 1750 } 1751 } 1752 1753 export const userMsgsList: {[key: string]: string} = { 1754 groupNodes: `Since the number of pods has surpassed the threshold pod count of 15, you will now be switched to the group node view. 1755 If you prefer the tree view, you can simply click on the Group Nodes toolbar button to deselect the current view.` 1756 }; 1757 1758 export function getAppUrl(app: appModels.Application): string { 1759 if (typeof app.metadata.namespace === 'undefined') { 1760 return `applications/${app.metadata.name}`; 1761 } 1762 return `applications/${app.metadata.namespace}/${app.metadata.name}`; 1763 } 1764 1765 export const getProgressiveSyncStatusIcon = ({status, isButton}: {status: string; isButton?: boolean}) => { 1766 const getIconProps = () => { 1767 switch (status) { 1768 case 'Healthy': 1769 return {icon: 'fa-check-circle', color: COLORS.health.healthy}; 1770 case 'Progressing': 1771 return {icon: 'fa-circle-notch fa-spin', color: COLORS.health.progressing}; 1772 case 'Pending': 1773 return {icon: 'fa-clock', color: COLORS.health.degraded}; 1774 case 'Waiting': 1775 return {icon: 'fa-clock', color: COLORS.sync.out_of_sync}; 1776 case 'Error': 1777 return {icon: 'fa-times-circle', color: COLORS.health.degraded}; 1778 case 'Synced': 1779 return {icon: 'fa-check-circle', color: COLORS.sync.synced}; 1780 case 'OutOfSync': 1781 return {icon: 'fa-exclamation-triangle', color: COLORS.sync.out_of_sync}; 1782 default: 1783 return {icon: 'fa-question-circle', color: COLORS.sync.unknown}; 1784 } 1785 }; 1786 1787 const {icon, color} = getIconProps(); 1788 const className = `fa ${icon}${isButton ? ' application-status-panel__item-value__status-button' : ''}`; 1789 return <i className={className} style={{color}} />; 1790 }; 1791 1792 export const getProgressiveSyncStatusColor = (status: string): string => { 1793 switch (status) { 1794 case 'Waiting': 1795 return COLORS.sync.out_of_sync; 1796 case 'Pending': 1797 return COLORS.health.degraded; 1798 case 'Progressing': 1799 return COLORS.health.progressing; 1800 case 'Healthy': 1801 return COLORS.health.healthy; 1802 case 'Error': 1803 return COLORS.health.degraded; 1804 case 'Synced': 1805 return COLORS.sync.synced; 1806 case 'OutOfSync': 1807 return COLORS.sync.out_of_sync; 1808 default: 1809 return COLORS.sync.unknown; 1810 } 1811 }; 1812 1813 // constant for podrequests 1814 export const podRequests = { 1815 CPU: 'Requests (CPU)', 1816 MEMORY: 'Requests (MEM)' 1817 } as const; 1818 1819 export function formatResourceInfo(name: string, value: string): {displayValue: string; tooltipValue: string} { 1820 const numValue = parseInt(value, 10); 1821 1822 const formatCPUValue = (milliCpu: number): string => { 1823 return milliCpu >= 1000 ? `${(milliCpu / 1000).toFixed(1)}` : `${milliCpu}m`; 1824 }; 1825 1826 const formatMemoryValue = (milliBytes: number): string => { 1827 const mib = Math.round(milliBytes / (1024 * 1024 * 1000)); 1828 return `${mib}Mi`; 1829 }; 1830 1831 const formatCPUTooltip = (milliCpu: number): string => { 1832 const displayValue = milliCpu >= 1000 ? `${(milliCpu / 1000).toFixed(1)} cores` : `${milliCpu}m`; 1833 return `CPU Request: ${displayValue}`; 1834 }; 1835 1836 const formatMemoryTooltip = (milliBytes: number): string => { 1837 const mib = Math.round(milliBytes / (1024 * 1024 * 1000)); 1838 return `Memory Request: ${mib}Mi`; 1839 }; 1840 1841 if (name === 'cpu') { 1842 return { 1843 displayValue: formatCPUValue(numValue), 1844 tooltipValue: formatCPUTooltip(numValue) 1845 }; 1846 } else if (name === 'memory') { 1847 return { 1848 displayValue: formatMemoryValue(numValue), 1849 tooltipValue: formatMemoryTooltip(numValue) 1850 }; 1851 } 1852 1853 return { 1854 displayValue: value, 1855 tooltipValue: `${name}: ${value}` 1856 }; 1857 }