argo-cd

Форк
0
1268 строк · 47.0 Кб
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
    const isManaged = !!resource.status;
329
    const deleteOptions = {
330
        option: 'foreground'
331
    };
332
    function handleStateChange(option: string) {
333
        deleteOptions.option = option;
334
    }
335
    return ctx.popup.prompt(
336
        'Delete resource',
337
        api => (
338
            <div>
339
                <p>
340
                    Are you sure you want to delete {resource.kind} <kbd>{resource.name}</kbd>?
341
                </p>
342
                {isManaged ? (
343
                    <div className='argo-form-row'>
344
                        <FormField label={`Please type '${resource.name}' to confirm the deletion of the resource`} formApi={api} field='resourceName' component={Text} />
345
                    </div>
346
                ) : (
347
                    ''
348
                )}
349
                <div className='argo-form-row'>
350
                    <input
351
                        type='radio'
352
                        name='deleteOptions'
353
                        value='foreground'
354
                        onChange={() => handleStateChange('foreground')}
355
                        defaultChecked={true}
356
                        style={{marginRight: '5px'}}
357
                        id='foreground-delete-radio'
358
                    />
359
                    <label htmlFor='foreground-delete-radio' style={{paddingRight: '30px'}}>
360
                        Foreground Delete {helpTip('Deletes the resource and dependent resources using the cascading policy in the foreground')}
361
                    </label>
362
                    <input type='radio' name='deleteOptions' value='force' onChange={() => handleStateChange('force')} style={{marginRight: '5px'}} id='force-delete-radio' />
363
                    <label htmlFor='force-delete-radio' style={{paddingRight: '30px'}}>
364
                        Background Delete {helpTip('Performs a forceful "background cascading deletion" of the resource and its dependent resources')}
365
                    </label>
366
                    <input type='radio' name='deleteOptions' value='orphan' onChange={() => handleStateChange('orphan')} style={{marginRight: '5px'}} id='cascade-delete-radio' />
367
                    <label htmlFor='cascade-delete-radio'>Non-cascading (Orphan) Delete {helpTip('Deletes the resource and orphans the dependent resources')}</label>
368
                </div>
369
            </div>
370
        ),
371
        {
372
            validate: vals =>
373
                isManaged && {
374
                    resourceName: vals.resourceName !== resource.name && 'Enter the resource name to confirm the deletion'
375
                },
376
            submit: async (vals, _, close) => {
377
                const force = deleteOptions.option === 'force';
378
                const orphan = deleteOptions.option === 'orphan';
379
                try {
380
                    await services.applications.deleteResource(application.metadata.name, application.metadata.namespace, resource, !!force, !!orphan);
381
                    if (appChanged) {
382
                        appChanged.next(await services.applications.get(application.metadata.name, application.metadata.namespace));
383
                    }
384
                    close();
385
                } catch (e) {
386
                    ctx.notifications.show({
387
                        content: <ErrorNotification title='Unable to delete resource' e={e} />,
388
                        type: NotificationType.Error
389
                    });
390
                }
391
            }
392
        },
393
        {name: 'argo-icon-warning', color: 'warning'},
394
        'yellow'
395
    );
396
};
397

398
function getResourceActionsMenuItems(resource: ResourceTreeNode, metadata: models.ObjectMeta, apis: ContextApis): Promise<ActionMenuItem[]> {
399
    return services.applications
400
        .getResourceActions(metadata.name, metadata.namespace, resource)
401
        .then(actions => {
402
            return actions.map(
403
                action =>
404
                    ({
405
                        title: action.displayName ?? action.name,
406
                        disabled: !!action.disabled,
407
                        iconClassName: action.iconClass,
408
                        action: async () => {
409
                            try {
410
                                const confirmed = await apis.popup.confirm(`Execute '${action.name}' action?`, `Are you sure you want to execute '${action.name}' action?`);
411
                                if (confirmed) {
412
                                    await services.applications.runResourceAction(metadata.name, metadata.namespace, resource, action.name);
413
                                }
414
                            } catch (e) {
415
                                apis.notifications.show({
416
                                    content: <ErrorNotification title='Unable to execute resource action' e={e} />,
417
                                    type: NotificationType.Error
418
                                });
419
                            }
420
                        }
421
                    } as MenuItem)
422
            );
423
        })
424
        .catch(() => [] as MenuItem[]);
425
}
426

427
function getActionItems(
428
    resource: ResourceTreeNode,
429
    application: appModels.Application,
430
    tree: appModels.ApplicationTree,
431
    apis: ContextApis,
432
    appChanged: BehaviorSubject<appModels.Application>,
433
    isQuickStart: boolean
434
): Observable<ActionMenuItem[]> {
435
    const isRoot = resource.root && nodeKey(resource.root) === nodeKey(resource);
436
    const items: MenuItem[] = [
437
        ...((isRoot && [
438
            {
439
                title: 'Sync',
440
                iconClassName: 'fa fa-fw fa-sync',
441
                action: () => showDeploy(nodeKey(resource), null, apis)
442
            }
443
        ]) ||
444
            []),
445
        {
446
            title: 'Delete',
447
            iconClassName: 'fa fa-fw fa-times-circle',
448
            action: async () => {
449
                return deletePopup(apis, resource, application, appChanged);
450
            }
451
        }
452
    ];
453
    if (!isQuickStart) {
454
        items.unshift({
455
            title: 'Details',
456
            iconClassName: 'fa fa-fw fa-info-circle',
457
            action: () => apis.navigation.goto('.', {node: nodeKey(resource)})
458
        });
459
    }
460

461
    if (findChildPod(resource, tree)) {
462
        items.push({
463
            title: 'Logs',
464
            iconClassName: 'fa fa-fw fa-align-left',
465
            action: () => apis.navigation.goto('.', {node: nodeKey(resource), tab: 'logs'}, {replace: true})
466
        });
467
    }
468

469
    if (isQuickStart) {
470
        return from([items]);
471
    }
472

473
    const execAction = services.authService
474
        .settings()
475
        .then(async settings => {
476
            const execAllowed = settings.execEnabled && (await services.accounts.canI('exec', 'create', application.spec.project + '/' + application.metadata.name));
477
            if (resource.kind === 'Pod' && execAllowed) {
478
                return [
479
                    {
480
                        title: 'Exec',
481
                        iconClassName: 'fa fa-fw fa-terminal',
482
                        action: async () => apis.navigation.goto('.', {node: nodeKey(resource), tab: 'exec'}, {replace: true})
483
                    } as MenuItem
484
                ];
485
            }
486
            return [] as MenuItem[];
487
        })
488
        .catch(() => [] as MenuItem[]);
489

490
    const resourceActions = getResourceActionsMenuItems(resource, application.metadata, apis);
491

492
    const links = services.applications
493
        .getResourceLinks(application.metadata.name, application.metadata.namespace, resource)
494
        .then(data => {
495
            return (data.items || []).map(
496
                link =>
497
                    ({
498
                        title: link.title,
499
                        iconClassName: `fa fa-fw ${link.iconClass ? link.iconClass : 'fa-external-link'}`,
500
                        action: () => window.open(link.url, '_blank'),
501
                        tooltip: link.description
502
                    } as MenuItem)
503
            );
504
        })
505
        .catch(() => [] as MenuItem[]);
506

507
    return combineLatest(
508
        from([items]), // this resolves immediately
509
        concat([[] as MenuItem[]], resourceActions), // this resolves at first to [] and then whatever the API returns
510
        concat([[] as MenuItem[]], execAction), // this resolves at first to [] and then whatever the API returns
511
        concat([[] as MenuItem[]], links) // this resolves at first to [] and then whatever the API returns
512
    ).pipe(map(res => ([] as MenuItem[]).concat(...res)));
513
}
514

515
export function renderResourceMenu(
516
    resource: ResourceTreeNode,
517
    application: appModels.Application,
518
    tree: appModels.ApplicationTree,
519
    apis: ContextApis,
520
    appChanged: BehaviorSubject<appModels.Application>,
521
    getApplicationActionMenu: () => any
522
): React.ReactNode {
523
    let menuItems: Observable<ActionMenuItem[]>;
524

525
    if (isAppNode(resource) && resource.name === application.metadata.name) {
526
        menuItems = from([getApplicationActionMenu()]);
527
    } else {
528
        menuItems = getActionItems(resource, application, tree, apis, appChanged, false);
529
    }
530
    return (
531
        <DataLoader load={() => menuItems}>
532
            {items => (
533
                <ul>
534
                    {items.map((item, i) => (
535
                        <li
536
                            className={classNames('application-details__action-menu', {disabled: item.disabled})}
537
                            key={i}
538
                            onClick={e => {
539
                                e.stopPropagation();
540
                                if (!item.disabled) {
541
                                    item.action();
542
                                    document.body.click();
543
                                }
544
                            }}>
545
                            {item.tooltip ? (
546
                                <Tooltip content={item.tooltip || ''}>
547
                                    <div>
548
                                        {item.iconClassName && <i className={item.iconClassName} />} {item.title}
549
                                    </div>
550
                                </Tooltip>
551
                            ) : (
552
                                <>
553
                                    {item.iconClassName && <i className={item.iconClassName} />} {item.title}
554
                                </>
555
                            )}
556
                        </li>
557
                    ))}
558
                </ul>
559
            )}
560
        </DataLoader>
561
    );
562
}
563

564
export function renderResourceActionMenu(resource: ResourceTreeNode, application: appModels.Application, apis: ContextApis): React.ReactNode {
565
    const menuItems = getResourceActionsMenuItems(resource, application.metadata, apis);
566

567
    return (
568
        <DataLoader load={() => menuItems}>
569
            {items => (
570
                <ul>
571
                    {items.map((item, i) => (
572
                        <li
573
                            className={classNames('application-details__action-menu', {disabled: item.disabled})}
574
                            key={i}
575
                            onClick={e => {
576
                                e.stopPropagation();
577
                                if (!item.disabled) {
578
                                    item.action();
579
                                    document.body.click();
580
                                }
581
                            }}>
582
                            {item.iconClassName && <i className={item.iconClassName} />} {item.title}
583
                        </li>
584
                    ))}
585
                </ul>
586
            )}
587
        </DataLoader>
588
    );
589
}
590

591
export function renderResourceButtons(
592
    resource: ResourceTreeNode,
593
    application: appModels.Application,
594
    tree: appModels.ApplicationTree,
595
    apis: ContextApis,
596
    appChanged: BehaviorSubject<appModels.Application>
597
): React.ReactNode {
598
    let menuItems: Observable<ActionMenuItem[]>;
599
    menuItems = getActionItems(resource, application, tree, apis, appChanged, true);
600
    return (
601
        <DataLoader load={() => menuItems}>
602
            {items => (
603
                <div className='pod-view__node__quick-start-actions'>
604
                    {items.map((item, i) => (
605
                        <ActionButton
606
                            disabled={item.disabled}
607
                            key={i}
608
                            action={(e: React.MouseEvent) => {
609
                                e.stopPropagation();
610
                                if (!item.disabled) {
611
                                    item.action();
612
                                    document.body.click();
613
                                }
614
                            }}
615
                            icon={item.iconClassName}
616
                            tooltip={
617
                                item.title
618
                                    .toString()
619
                                    .charAt(0)
620
                                    .toUpperCase() + item.title.toString().slice(1)
621
                            }
622
                        />
623
                    ))}
624
                </div>
625
            )}
626
        </DataLoader>
627
    );
628
}
629

630
export function syncStatusMessage(app: appModels.Application) {
631
    const source = getAppDefaultSource(app);
632
    const rev = app.status.sync.revision || source.targetRevision || 'HEAD';
633
    let message = source.targetRevision || 'HEAD';
634

635
    if (app.status.sync.revision) {
636
        if (source.chart) {
637
            message += ' (' + app.status.sync.revision + ')';
638
        } else if (app.status.sync.revision.length >= 7 && !app.status.sync.revision.startsWith(source.targetRevision)) {
639
            message += ' (' + app.status.sync.revision.substr(0, 7) + ')';
640
        }
641
    }
642
    switch (app.status.sync.status) {
643
        case appModels.SyncStatuses.Synced:
644
            return (
645
                <span>
646
                    to{' '}
647
                    <Revision repoUrl={source.repoURL} revision={rev}>
648
                        {message}
649
                    </Revision>{' '}
650
                </span>
651
            );
652
        case appModels.SyncStatuses.OutOfSync:
653
            return (
654
                <span>
655
                    from{' '}
656
                    <Revision repoUrl={source.repoURL} revision={rev}>
657
                        {message}
658
                    </Revision>{' '}
659
                </span>
660
            );
661
        default:
662
            return <span>{message}</span>;
663
    }
664
}
665

666
export const HealthStatusIcon = ({state, noSpin}: {state: appModels.HealthStatus; noSpin?: boolean}) => {
667
    let color = COLORS.health.unknown;
668
    let icon = 'fa-question-circle';
669

670
    switch (state.status) {
671
        case appModels.HealthStatuses.Healthy:
672
            color = COLORS.health.healthy;
673
            icon = 'fa-heart';
674
            break;
675
        case appModels.HealthStatuses.Suspended:
676
            color = COLORS.health.suspended;
677
            icon = 'fa-pause-circle';
678
            break;
679
        case appModels.HealthStatuses.Degraded:
680
            color = COLORS.health.degraded;
681
            icon = 'fa-heart-broken';
682
            break;
683
        case appModels.HealthStatuses.Progressing:
684
            color = COLORS.health.progressing;
685
            icon = `fa fa-circle-notch ${noSpin ? '' : 'fa-spin'}`;
686
            break;
687
        case appModels.HealthStatuses.Missing:
688
            color = COLORS.health.missing;
689
            icon = 'fa-ghost';
690
            break;
691
    }
692
    let title: string = state.status;
693
    if (state.message) {
694
        title = `${state.status}: ${state.message}`;
695
    }
696
    return <i qe-id='utils-health-status-title' title={title} className={'fa ' + icon} style={{color}} />;
697
};
698

699
export const PodHealthIcon = ({state}: {state: appModels.HealthStatus}) => {
700
    let icon = 'fa-question-circle';
701

702
    switch (state.status) {
703
        case appModels.HealthStatuses.Healthy:
704
            icon = 'fa-check';
705
            break;
706
        case appModels.HealthStatuses.Suspended:
707
            icon = 'fa-check';
708
            break;
709
        case appModels.HealthStatuses.Degraded:
710
            icon = 'fa-times';
711
            break;
712
        case appModels.HealthStatuses.Progressing:
713
            icon = 'fa fa-circle-notch fa-spin';
714
            break;
715
    }
716
    let title: string = state.status;
717
    if (state.message) {
718
        title = `${state.status}: ${state.message}`;
719
    }
720
    return <i qe-id='utils-health-status-title' title={title} className={'fa ' + icon} />;
721
};
722

723
export const PodPhaseIcon = ({state}: {state: appModels.PodPhase}) => {
724
    let className = '';
725
    switch (state) {
726
        case appModels.PodPhase.PodSucceeded:
727
            className = 'fa fa-check';
728
            break;
729
        case appModels.PodPhase.PodRunning:
730
            className = 'fa fa-circle-notch fa-spin';
731
            break;
732
        case appModels.PodPhase.PodPending:
733
            className = 'fa fa-circle-notch fa-spin';
734
            break;
735
        case appModels.PodPhase.PodFailed:
736
            className = 'fa fa-times';
737
            break;
738
        default:
739
            className = 'fa fa-question-circle';
740
            break;
741
    }
742
    return <i qe-id='utils-pod-phase-icon' className={className} />;
743
};
744

745
export const ResourceResultIcon = ({resource}: {resource: appModels.ResourceResult}) => {
746
    let color = COLORS.sync_result.unknown;
747
    let icon = 'fas fa-question-circle';
748

749
    if (!resource.hookType && resource.status) {
750
        switch (resource.status) {
751
            case appModels.ResultCodes.Synced:
752
                color = COLORS.sync_result.synced;
753
                icon = 'fa-heart';
754
                break;
755
            case appModels.ResultCodes.Pruned:
756
                color = COLORS.sync_result.pruned;
757
                icon = 'fa-heart';
758
                break;
759
            case appModels.ResultCodes.SyncFailed:
760
                color = COLORS.sync_result.failed;
761
                icon = 'fa-heart-broken';
762
                break;
763
            case appModels.ResultCodes.PruneSkipped:
764
                icon = 'fa-heart';
765
                break;
766
        }
767
        let title: string = resource.message;
768
        if (resource.message) {
769
            title = `${resource.status}: ${resource.message}`;
770
        }
771
        return <i title={title} className={'fa ' + icon} style={{color}} />;
772
    }
773
    if (resource.hookType && resource.hookPhase) {
774
        let className = '';
775
        switch (resource.hookPhase) {
776
            case appModels.OperationPhases.Running:
777
                color = COLORS.operation.running;
778
                className = 'fa fa-circle-notch fa-spin';
779
                break;
780
            case appModels.OperationPhases.Failed:
781
                color = COLORS.operation.failed;
782
                className = 'fa fa-heart-broken';
783
                break;
784
            case appModels.OperationPhases.Error:
785
                color = COLORS.operation.error;
786
                className = 'fa fa-heart-broken';
787
                break;
788
            case appModels.OperationPhases.Succeeded:
789
                color = COLORS.operation.success;
790
                className = 'fa fa-heart';
791
                break;
792
            case appModels.OperationPhases.Terminating:
793
                color = COLORS.operation.terminating;
794
                className = 'fa fa-circle-notch fa-spin';
795
                break;
796
        }
797
        let title: string = resource.message;
798
        if (resource.message) {
799
            title = `${resource.hookPhase}: ${resource.message}`;
800
        }
801
        return <i title={title} className={className} style={{color}} />;
802
    }
803
    return null;
804
};
805

806
export const getAppOperationState = (app: appModels.Application): appModels.OperationState => {
807
    if (app.operation) {
808
        return {
809
            phase: appModels.OperationPhases.Running,
810
            message: (app.status && app.status.operationState && app.status.operationState.message) || 'waiting to start',
811
            startedAt: new Date().toISOString(),
812
            operation: {
813
                sync: {}
814
            }
815
        } as appModels.OperationState;
816
    } else if (app.metadata.deletionTimestamp) {
817
        return {
818
            phase: appModels.OperationPhases.Running,
819
            startedAt: app.metadata.deletionTimestamp
820
        } as appModels.OperationState;
821
    } else {
822
        return app.status.operationState;
823
    }
824
};
825

826
export function getOperationType(application: appModels.Application) {
827
    const operation = application.operation || (application.status && application.status.operationState && application.status.operationState.operation);
828
    if (application.metadata.deletionTimestamp && !application.operation) {
829
        return 'Delete';
830
    }
831
    if (operation && operation.sync) {
832
        return 'Sync';
833
    }
834
    return 'Unknown';
835
}
836

837
const getOperationStateTitle = (app: appModels.Application) => {
838
    const appOperationState = getAppOperationState(app);
839
    const operationType = getOperationType(app);
840
    switch (operationType) {
841
        case 'Delete':
842
            return 'Deleting';
843
        case 'Sync':
844
            switch (appOperationState.phase) {
845
                case 'Running':
846
                    return 'Syncing';
847
                case 'Error':
848
                    return 'Sync error';
849
                case 'Failed':
850
                    return 'Sync failed';
851
                case 'Succeeded':
852
                    return 'Sync OK';
853
                case 'Terminating':
854
                    return 'Terminated';
855
            }
856
    }
857
    return 'Unknown';
858
};
859

860
export const OperationState = ({app, quiet}: {app: appModels.Application; quiet?: boolean}) => {
861
    const appOperationState = getAppOperationState(app);
862
    if (appOperationState === undefined) {
863
        return <React.Fragment />;
864
    }
865
    if (quiet && [appModels.OperationPhases.Running, appModels.OperationPhases.Failed, appModels.OperationPhases.Error].indexOf(appOperationState.phase) === -1) {
866
        return <React.Fragment />;
867
    }
868

869
    return (
870
        <React.Fragment>
871
            <OperationPhaseIcon app={app} /> {getOperationStateTitle(app)}
872
        </React.Fragment>
873
    );
874
};
875

876
export function getPodStateReason(pod: appModels.State): {message: string; reason: string; netContainerStatuses: any[]} {
877
    let reason = pod.status.phase;
878
    let message = '';
879
    if (pod.status.reason) {
880
        reason = pod.status.reason;
881
    }
882

883
    let initializing = false;
884

885
    let netContainerStatuses = pod.status.initContainerStatuses || [];
886
    netContainerStatuses = netContainerStatuses.concat(pod.status.containerStatuses || []);
887

888
    for (const container of (pod.status.initContainerStatuses || []).slice().reverse()) {
889
        if (container.state.terminated && container.state.terminated.exitCode === 0) {
890
            continue;
891
        }
892

893
        if (container.state.terminated) {
894
            if (container.state.terminated.reason) {
895
                reason = `Init:ExitCode:${container.state.terminated.exitCode}`;
896
            } else {
897
                reason = `Init:${container.state.terminated.reason}`;
898
                message = container.state.terminated.message;
899
            }
900
        } else if (container.state.waiting && container.state.waiting.reason && container.state.waiting.reason !== 'PodInitializing') {
901
            reason = `Init:${container.state.waiting.reason}`;
902
            message = `Init:${container.state.waiting.message}`;
903
        } else {
904
            reason = `Init: ${(pod.spec.initContainers || []).length})`;
905
        }
906
        initializing = true;
907
        break;
908
    }
909

910
    if (!initializing) {
911
        let hasRunning = false;
912
        for (const container of pod.status.containerStatuses || []) {
913
            if (container.state.waiting && container.state.waiting.reason) {
914
                reason = container.state.waiting.reason;
915
                message = container.state.waiting.message;
916
            } else if (container.state.terminated && container.state.terminated.reason) {
917
                reason = container.state.terminated.reason;
918
                message = container.state.terminated.message;
919
            } else if (container.state.terminated && !container.state.terminated.reason) {
920
                if (container.state.terminated.signal !== 0) {
921
                    reason = `Signal:${container.state.terminated.signal}`;
922
                    message = '';
923
                } else {
924
                    reason = `ExitCode:${container.state.terminated.exitCode}`;
925
                    message = '';
926
                }
927
            } else if (container.ready && container.state.running) {
928
                hasRunning = true;
929
            }
930
        }
931

932
        // change pod status back to 'Running' if there is at least one container still reporting as 'Running' status
933
        if (reason === 'Completed' && hasRunning) {
934
            reason = 'Running';
935
            message = '';
936
        }
937
    }
938

939
    if ((pod as any).metadata.deletionTimestamp && pod.status.reason === 'NodeLost') {
940
        reason = 'Unknown';
941
        message = '';
942
    } else if ((pod as any).metadata.deletionTimestamp) {
943
        reason = 'Terminating';
944
        message = '';
945
    }
946

947
    return {reason, message, netContainerStatuses};
948
}
949

950
export const getPodReadinessGatesState = (pod: appModels.State): {nonExistingConditions: string[]; notPassedConditions: string[]} => {
951
    // if pod does not have readiness gates then return empty status
952
    if (!pod.spec?.readinessGates?.length) {
953
        return {
954
            nonExistingConditions: [],
955
            notPassedConditions: []
956
        };
957
    }
958

959
    const existingConditions = new Map<string, boolean>();
960
    const podConditions = new Map<string, boolean>();
961

962
    const podStatusConditions = pod.status?.conditions || [];
963

964
    for (const condition of podStatusConditions) {
965
        existingConditions.set(condition.type, true);
966
        // priority order of conditions
967
        // eg. if there are multiple conditions set with same name then the one which comes first is evaluated
968
        if (podConditions.has(condition.type)) {
969
            continue;
970
        }
971

972
        if (condition.status === 'False') {
973
            podConditions.set(condition.type, false);
974
        } else if (condition.status === 'True') {
975
            podConditions.set(condition.type, true);
976
        }
977
    }
978

979
    const nonExistingConditions: string[] = [];
980
    const failedConditions: string[] = [];
981

982
    const readinessGates: appModels.ReadinessGate[] = pod.spec?.readinessGates || [];
983

984
    for (const readinessGate of readinessGates) {
985
        if (!existingConditions.has(readinessGate.conditionType)) {
986
            nonExistingConditions.push(readinessGate.conditionType);
987
        } else if (podConditions.get(readinessGate.conditionType) === false) {
988
            failedConditions.push(readinessGate.conditionType);
989
        }
990
    }
991

992
    return {
993
        nonExistingConditions,
994
        notPassedConditions: failedConditions
995
    };
996
};
997

998
export function getConditionCategory(condition: appModels.ApplicationCondition): 'error' | 'warning' | 'info' {
999
    if (condition.type.endsWith('Error')) {
1000
        return 'error';
1001
    } else if (condition.type.endsWith('Warning')) {
1002
        return 'warning';
1003
    } else {
1004
        return 'info';
1005
    }
1006
}
1007

1008
export function isAppNode(node: appModels.ResourceNode) {
1009
    return node.kind === 'Application' && node.group === 'argoproj.io';
1010
}
1011

1012
export function getAppOverridesCount(app: appModels.Application) {
1013
    const source = getAppDefaultSource(app);
1014
    if (source.kustomize && source.kustomize.images) {
1015
        return source.kustomize.images.length;
1016
    }
1017
    if (source.helm && source.helm.parameters) {
1018
        return source.helm.parameters.length;
1019
    }
1020
    return 0;
1021
}
1022

1023
// getAppDefaultSource gets the first app source from `sources` or, if that list is missing or empty, the `source`
1024
// field.
1025
export function getAppDefaultSource(app?: appModels.Application) {
1026
    if (!app) {
1027
        return null;
1028
    }
1029
    return app.spec.sources && app.spec.sources.length > 0 ? app.spec.sources[0] : app.spec.source;
1030
}
1031

1032
export function getAppSpecDefaultSource(spec: appModels.ApplicationSpec) {
1033
    return spec.sources && spec.sources.length > 0 ? spec.sources[0] : spec.source;
1034
}
1035

1036
export function isAppRefreshing(app: appModels.Application) {
1037
    return !!(app.metadata.annotations && app.metadata.annotations[appModels.AnnotationRefreshKey]);
1038
}
1039

1040
export function setAppRefreshing(app: appModels.Application) {
1041
    if (!app.metadata.annotations) {
1042
        app.metadata.annotations = {};
1043
    }
1044
    if (!app.metadata.annotations[appModels.AnnotationRefreshKey]) {
1045
        app.metadata.annotations[appModels.AnnotationRefreshKey] = 'refreshing';
1046
    }
1047
}
1048

1049
export function refreshLinkAttrs(app: appModels.Application) {
1050
    return {disabled: isAppRefreshing(app)};
1051
}
1052

1053
export const SyncWindowStatusIcon = ({state, window}: {state: appModels.SyncWindowsState; window: appModels.SyncWindow}) => {
1054
    let className = '';
1055
    let color = '';
1056
    let current = '';
1057

1058
    if (state.windows === undefined) {
1059
        current = 'Inactive';
1060
    } else {
1061
        for (const w of state.windows) {
1062
            if (w.kind === window.kind && w.schedule === window.schedule && w.duration === window.duration && w.timeZone === window.timeZone) {
1063
                current = 'Active';
1064
                break;
1065
            } else {
1066
                current = 'Inactive';
1067
            }
1068
        }
1069
    }
1070

1071
    switch (current + ':' + window.kind) {
1072
        case 'Active:deny':
1073
        case 'Inactive:allow':
1074
            className = 'fa fa-stop-circle';
1075
            if (window.manualSync) {
1076
                color = COLORS.sync_window.manual;
1077
            } else {
1078
                color = COLORS.sync_window.deny;
1079
            }
1080
            break;
1081
        case 'Active:allow':
1082
        case 'Inactive:deny':
1083
            className = 'fa fa-check-circle';
1084
            color = COLORS.sync_window.allow;
1085
            break;
1086
        default:
1087
            className = 'fas fa-question-circle';
1088
            color = COLORS.sync_window.unknown;
1089
            current = 'Unknown';
1090
            break;
1091
    }
1092

1093
    return (
1094
        <React.Fragment>
1095
            <i title={current} className={className} style={{color}} /> {current}
1096
        </React.Fragment>
1097
    );
1098
};
1099

1100
export const ApplicationSyncWindowStatusIcon = ({project, state}: {project: string; state: appModels.ApplicationSyncWindowState}) => {
1101
    let className = '';
1102
    let color = '';
1103
    let deny = false;
1104
    let allow = false;
1105
    let inactiveAllow = false;
1106
    if (state.assignedWindows !== undefined && state.assignedWindows.length > 0) {
1107
        if (state.activeWindows !== undefined && state.activeWindows.length > 0) {
1108
            for (const w of state.activeWindows) {
1109
                if (w.kind === 'deny') {
1110
                    deny = true;
1111
                } else if (w.kind === 'allow') {
1112
                    allow = true;
1113
                }
1114
            }
1115
        }
1116
        for (const a of state.assignedWindows) {
1117
            if (a.kind === 'allow') {
1118
                inactiveAllow = true;
1119
            }
1120
        }
1121
    } else {
1122
        allow = true;
1123
    }
1124

1125
    if (deny || (!deny && !allow && inactiveAllow)) {
1126
        className = 'fa fa-stop-circle';
1127
        if (state.canSync) {
1128
            color = COLORS.sync_window.manual;
1129
        } else {
1130
            color = COLORS.sync_window.deny;
1131
        }
1132
    } else {
1133
        className = 'fa fa-check-circle';
1134
        color = COLORS.sync_window.allow;
1135
    }
1136

1137
    const ctx = React.useContext(Context);
1138

1139
    return (
1140
        <a href={`${ctx.baseHref}settings/projects/${project}?tab=windows`} style={{color}}>
1141
            <i className={className} style={{color}} /> SyncWindow
1142
        </a>
1143
    );
1144
};
1145

1146
/**
1147
 * Automatically stops and restarts the given observable when page visibility changes.
1148
 */
1149
export function handlePageVisibility<T>(src: () => Observable<T>): Observable<T> {
1150
    return new Observable<T>((observer: Observer<T>) => {
1151
        let subscription: Subscription;
1152
        const ensureUnsubscribed = () => {
1153
            if (subscription) {
1154
                subscription.unsubscribe();
1155
                subscription = null;
1156
            }
1157
        };
1158
        const start = () => {
1159
            ensureUnsubscribed();
1160
            subscription = src().subscribe(
1161
                (item: T) => observer.next(item),
1162
                err => observer.error(err),
1163
                () => observer.complete()
1164
            );
1165
        };
1166

1167
        if (!document.hidden) {
1168
            start();
1169
        }
1170

1171
        const visibilityChangeSubscription = fromEvent(document, 'visibilitychange')
1172
            // wait until user stop clicking back and forth to avoid restarting observable too often
1173
            .pipe(debounceTime(500))
1174
            .subscribe(() => {
1175
                if (document.hidden && subscription) {
1176
                    ensureUnsubscribed();
1177
                } else if (!document.hidden && !subscription) {
1178
                    start();
1179
                }
1180
            });
1181

1182
        return () => {
1183
            visibilityChangeSubscription.unsubscribe();
1184
            ensureUnsubscribed();
1185
        };
1186
    });
1187
}
1188

1189
export function parseApiVersion(apiVersion: string): {group: string; version: string} {
1190
    const parts = apiVersion.split('/');
1191
    if (parts.length > 1) {
1192
        return {group: parts[0], version: parts[1]};
1193
    }
1194
    return {version: parts[0], group: ''};
1195
}
1196

1197
export function getContainerName(pod: any, containerIndex: number | null): string {
1198
    if (containerIndex == null && pod.metadata?.annotations?.['kubectl.kubernetes.io/default-container']) {
1199
        return pod.metadata?.annotations?.['kubectl.kubernetes.io/default-container'];
1200
    }
1201
    const containers = (pod.spec.containers || []).concat(pod.spec.initContainers || []);
1202
    const container = containers[containerIndex || 0];
1203
    return container.name;
1204
}
1205

1206
export function isYoungerThanXMinutes(pod: any, x: number): boolean {
1207
    const createdAt = moment(pod.createdAt, 'YYYY-MM-DDTHH:mm:ssZ');
1208
    const xMinutesAgo = moment().subtract(x, 'minutes');
1209
    return createdAt.isAfter(xMinutesAgo);
1210
}
1211

1212
export const BASE_COLORS = [
1213
    '#0DADEA', // blue
1214
    '#DE7EAE', // pink
1215
    '#FF9500', // orange
1216
    '#4B0082', // purple
1217
    '#F5d905', // yellow
1218
    '#964B00' // brown
1219
];
1220

1221
export const urlPattern = new RegExp(
1222
    new RegExp(
1223
        // tslint:disable-next-line:max-line-length
1224
        /^(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,})$/,
1225
        'gi'
1226
    )
1227
);
1228

1229
export function appQualifiedName(app: appModels.Application, nsEnabled: boolean): string {
1230
    return (nsEnabled ? app.metadata.namespace + '/' : '') + app.metadata.name;
1231
}
1232

1233
export function appInstanceName(app: appModels.Application): string {
1234
    return app.metadata.namespace + '_' + app.metadata.name;
1235
}
1236

1237
export function formatCreationTimestamp(creationTimestamp: string) {
1238
    const createdAt = moment
1239
        .utc(creationTimestamp)
1240
        .local()
1241
        .format('MM/DD/YYYY HH:mm:ss');
1242
    const fromNow = moment
1243
        .utc(creationTimestamp)
1244
        .local()
1245
        .fromNow();
1246
    return (
1247
        <span>
1248
            {createdAt}
1249
            <i style={{padding: '2px'}} /> ({fromNow})
1250
        </span>
1251
    );
1252
}
1253

1254
export const selectPostfix = (arr: string[], singular: string, plural: string) => (arr.length > 1 ? plural : singular);
1255

1256
export function getUsrMsgKeyToDisplay(appName: string, msgKey: string, usrMessages: appModels.UserMessages[]) {
1257
    const usrMsg = usrMessages?.find((msg: appModels.UserMessages) => msg.appName === appName && msg.msgKey === msgKey);
1258
    if (usrMsg !== undefined) {
1259
        return {...usrMsg, display: true};
1260
    } else {
1261
        return {appName, msgKey, display: false, duration: 1} as appModels.UserMessages;
1262
    }
1263
}
1264

1265
export const userMsgsList: {[key: string]: string} = {
1266
    groupNodes: `Since the number of pods has surpassed the threshold pod count of 15, you will now be switched to the group node view.
1267
                 If you prefer the tree view, you can simply click on the Group Nodes toolbar button to deselect the current view.`
1268
};
1269

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.