argo-cd

Форк
0
616 строк · 30.8 Кб
1
import {AutocompleteField, DropDownMenu, ErrorNotification, FormField, FormSelect, HelpIcon, NotificationType} from 'argo-ui';
2
import * as React from 'react';
3
import {FormApi, Text} from 'react-form';
4
import {
5
    ClipboardText,
6
    Cluster,
7
    DataLoader,
8
    EditablePanel,
9
    EditablePanelItem,
10
    Expandable,
11
    MapInputField,
12
    NumberField,
13
    Repo,
14
    Revision,
15
    RevisionHelpIcon
16
} from '../../../shared/components';
17
import {BadgePanel, Spinner} from '../../../shared/components';
18
import {AuthSettingsCtx, Consumer, ContextApis} from '../../../shared/context';
19
import * as models from '../../../shared/models';
20
import {services} from '../../../shared/services';
21

22
import {ApplicationSyncOptionsField} from '../application-sync-options/application-sync-options';
23
import {RevisionFormField} from '../revision-form-field/revision-form-field';
24
import {ComparisonStatusIcon, HealthStatusIcon, syncStatusMessage, urlPattern, formatCreationTimestamp, getAppDefaultSource, getAppSpecDefaultSource, helpTip} from '../utils';
25
import {ApplicationRetryOptions} from '../application-retry-options/application-retry-options';
26
import {ApplicationRetryView} from '../application-retry-view/application-retry-view';
27
import {Link} from 'react-router-dom';
28
import {EditNotificationSubscriptions, useEditNotificationSubscriptions} from './edit-notification-subscriptions';
29
import {EditAnnotations} from './edit-annotations';
30

31
import './application-summary.scss';
32
import {DeepLinks} from '../../../shared/components/deep-links';
33

34
function swap(array: any[], a: number, b: number) {
35
    array = array.slice();
36
    [array[a], array[b]] = [array[b], array[a]];
37
    return array;
38
}
39

40
function processPath(path: string) {
41
    if (path !== null && path !== undefined) {
42
        if (path === '.') {
43
            return '(root)';
44
        }
45
        return path;
46
    }
47
    return '';
48
}
49

50
export interface ApplicationSummaryProps {
51
    app: models.Application;
52
    updateApp: (app: models.Application, query: {validate?: boolean}) => Promise<any>;
53
}
54

55
export const ApplicationSummary = (props: ApplicationSummaryProps) => {
56
    const app = JSON.parse(JSON.stringify(props.app)) as models.Application;
57
    const source = getAppDefaultSource(app);
58
    const isHelm = source.hasOwnProperty('chart');
59
    const initialState = app.spec.destination.server === undefined ? 'NAME' : 'URL';
60
    const useAuthSettingsCtx = React.useContext(AuthSettingsCtx);
61
    const [destFormat, setDestFormat] = React.useState(initialState);
62
    const [changeSync, setChangeSync] = React.useState(false);
63

64
    const notificationSubscriptions = useEditNotificationSubscriptions(app.metadata.annotations || {});
65
    const updateApp = notificationSubscriptions.withNotificationSubscriptions(props.updateApp);
66

67
    const hasMultipleSources = app.spec.sources && app.spec.sources.length > 0;
68

69
    const attributes = [
70
        {
71
            title: 'PROJECT',
72
            view: <Link to={'/settings/projects/' + app.spec.project}>{app.spec.project}</Link>,
73
            edit: (formApi: FormApi) => (
74
                <DataLoader load={() => services.projects.list('items.metadata.name').then(projs => projs.map(item => item.metadata.name))}>
75
                    {projects => <FormField formApi={formApi} field='spec.project' component={FormSelect} componentProps={{options: projects}} />}
76
                </DataLoader>
77
            )
78
        },
79
        {
80
            title: 'LABELS',
81
            view: Object.keys(app.metadata.labels || {})
82
                .map(label => `${label}=${app.metadata.labels[label]}`)
83
                .join(' '),
84
            edit: (formApi: FormApi) => <FormField formApi={formApi} field='metadata.labels' component={MapInputField} />
85
        },
86
        {
87
            title: 'ANNOTATIONS',
88
            view: (
89
                <Expandable height={48}>
90
                    {Object.keys(app.metadata.annotations || {})
91
                        .map(annotation => `${annotation}=${app.metadata.annotations[annotation]}`)
92
                        .join(' ')}
93
                </Expandable>
94
            ),
95
            edit: (formApi: FormApi) => <EditAnnotations formApi={formApi} app={app} />
96
        },
97
        {
98
            title: 'NOTIFICATION SUBSCRIPTIONS',
99
            view: false, // eventually the subscription input values will be merged in 'ANNOTATIONS', therefore 'ANNOATIONS' section is responsible to represent subscription values,
100
            edit: () => <EditNotificationSubscriptions {...notificationSubscriptions} />
101
        },
102
        {
103
            title: 'CLUSTER',
104
            view: <Cluster server={app.spec.destination.server} name={app.spec.destination.name} showUrl={true} />,
105
            edit: (formApi: FormApi) => (
106
                <DataLoader load={() => services.clusters.list().then(clusters => clusters.sort())}>
107
                    {clusters => {
108
                        return (
109
                            <div className='row'>
110
                                {(destFormat.toUpperCase() === 'URL' && (
111
                                    <div className='columns small-10'>
112
                                        <FormField
113
                                            formApi={formApi}
114
                                            field='spec.destination.server'
115
                                            componentProps={{items: clusters.map(cluster => cluster.server)}}
116
                                            component={AutocompleteField}
117
                                        />
118
                                    </div>
119
                                )) || (
120
                                    <div className='columns small-10'>
121
                                        <FormField
122
                                            formApi={formApi}
123
                                            field='spec.destination.name'
124
                                            componentProps={{items: clusters.map(cluster => cluster.name)}}
125
                                            component={AutocompleteField}
126
                                        />
127
                                    </div>
128
                                )}
129
                                <div className='columns small-2'>
130
                                    <div>
131
                                        <DropDownMenu
132
                                            anchor={() => (
133
                                                <p>
134
                                                    {destFormat.toUpperCase()} <i className='fa fa-caret-down' />
135
                                                </p>
136
                                            )}
137
                                            items={['URL', 'NAME'].map((type: 'URL' | 'NAME') => ({
138
                                                title: type,
139
                                                action: () => {
140
                                                    if (destFormat !== type) {
141
                                                        const updatedApp = formApi.getFormState().values as models.Application;
142
                                                        if (type === 'URL') {
143
                                                            updatedApp.spec.destination.server = '';
144
                                                            delete updatedApp.spec.destination.name;
145
                                                        } else {
146
                                                            updatedApp.spec.destination.name = '';
147
                                                            delete updatedApp.spec.destination.server;
148
                                                        }
149
                                                        formApi.setAllValues(updatedApp);
150
                                                        setDestFormat(type);
151
                                                    }
152
                                                }
153
                                            }))}
154
                                        />
155
                                    </div>
156
                                </div>
157
                            </div>
158
                        );
159
                    }}
160
                </DataLoader>
161
            )
162
        },
163
        {
164
            title: 'NAMESPACE',
165
            view: <ClipboardText text={app.spec.destination.namespace} />,
166
            edit: (formApi: FormApi) => <FormField formApi={formApi} field='spec.destination.namespace' component={Text} />
167
        },
168
        {
169
            title: 'CREATED AT',
170
            view: formatCreationTimestamp(app.metadata.creationTimestamp)
171
        },
172
        {
173
            title: 'REPO URL',
174
            view: <Repo url={source.repoURL} />,
175
            edit: (formApi: FormApi) =>
176
                hasMultipleSources ? (
177
                    helpTip('REPO URL is not editable for applications with multiple sources. You can edit them in the "Manifest" tab.')
178
                ) : (
179
                    <FormField formApi={formApi} field='spec.source.repoURL' component={Text} />
180
                )
181
        },
182
        ...(isHelm
183
            ? [
184
                  {
185
                      title: 'CHART',
186
                      view: (
187
                          <span>
188
                              {source.chart}:{source.targetRevision}
189
                          </span>
190
                      ),
191
                      edit: (formApi: FormApi) =>
192
                          hasMultipleSources ? (
193
                              helpTip('CHART is not editable for applications with multiple sources. You can edit them in the "Manifest" tab.')
194
                          ) : (
195
                              <DataLoader
196
                                  input={{repoURL: getAppSpecDefaultSource(formApi.getFormState().values.spec).repoURL}}
197
                                  load={src => services.repos.charts(src.repoURL).catch(() => new Array<models.HelmChart>())}>
198
                                  {(charts: models.HelmChart[]) => (
199
                                      <div className='row'>
200
                                          <div className='columns small-8'>
201
                                              <FormField
202
                                                  formApi={formApi}
203
                                                  field='spec.source.chart'
204
                                                  component={AutocompleteField}
205
                                                  componentProps={{
206
                                                      items: charts.map(chart => chart.name),
207
                                                      filterSuggestions: true
208
                                                  }}
209
                                              />
210
                                          </div>
211
                                          <DataLoader
212
                                              input={{charts, chart: getAppSpecDefaultSource(formApi.getFormState().values.spec).chart}}
213
                                              load={async data => {
214
                                                  const chartInfo = data.charts.find(chart => chart.name === data.chart);
215
                                                  return (chartInfo && chartInfo.versions) || new Array<string>();
216
                                              }}>
217
                                              {(versions: string[]) => (
218
                                                  <div className='columns small-4'>
219
                                                      <FormField
220
                                                          formApi={formApi}
221
                                                          field='spec.source.targetRevision'
222
                                                          component={AutocompleteField}
223
                                                          componentProps={{
224
                                                              items: versions
225
                                                          }}
226
                                                      />
227
                                                      <RevisionHelpIcon type='helm' top='0' />
228
                                                  </div>
229
                                              )}
230
                                          </DataLoader>
231
                                      </div>
232
                                  )}
233
                              </DataLoader>
234
                          )
235
                  }
236
              ]
237
            : [
238
                  {
239
                      title: 'TARGET REVISION',
240
                      view: <Revision repoUrl={source.repoURL} revision={source.targetRevision || 'HEAD'} />,
241
                      edit: (formApi: FormApi) =>
242
                          hasMultipleSources ? (
243
                              helpTip('TARGET REVISION is not editable for applications with multiple sources. You can edit them in the "Manifest" tab.')
244
                          ) : (
245
                              <RevisionFormField helpIconTop={'0'} hideLabel={true} formApi={formApi} repoURL={source.repoURL} />
246
                          )
247
                  },
248
                  {
249
                      title: 'PATH',
250
                      view: (
251
                          <Revision repoUrl={source.repoURL} revision={source.targetRevision || 'HEAD'} path={source.path} isForPath={true}>
252
                              {processPath(source.path)}
253
                          </Revision>
254
                      ),
255
                      edit: (formApi: FormApi) =>
256
                          hasMultipleSources ? (
257
                              helpTip('PATH is not editable for applications with multiple sources. You can edit them in the "Manifest" tab.')
258
                          ) : (
259
                              <FormField formApi={formApi} field='spec.source.path' component={Text} />
260
                          )
261
                  }
262
              ]),
263

264
        {
265
            title: 'REVISION HISTORY LIMIT',
266
            view: app.spec.revisionHistoryLimit,
267
            edit: (formApi: FormApi) => (
268
                <div style={{position: 'relative'}}>
269
                    <FormField formApi={formApi} field='spec.revisionHistoryLimit' componentProps={{style: {paddingRight: '1em'}, placeholder: '10'}} component={NumberField} />
270
                    <div style={{position: 'absolute', right: '0', top: '0'}}>
271
                        <HelpIcon
272
                            title='This limits the number of items kept in the apps revision history.
273
    This should only be changed in exceptional circumstances.
274
    Setting to zero will store no history. This will reduce storage used.
275
    Increasing will increase the space used to store the history, so we do not recommend increasing it.
276
    Default is 10.'
277
                        />
278
                    </div>
279
                </div>
280
            )
281
        },
282
        {
283
            title: 'SYNC OPTIONS',
284
            view: (
285
                <div style={{display: 'flex', flexWrap: 'wrap'}}>
286
                    {((app.spec.syncPolicy || {}).syncOptions || []).map(opt =>
287
                        opt.endsWith('=true') || opt.endsWith('=false') ? (
288
                            <div key={opt} style={{marginRight: '10px'}}>
289
                                <i className={`fa fa-${opt.includes('=true') ? 'check-square' : 'times'}`} /> {opt.replace('=true', '').replace('=false', '')}
290
                            </div>
291
                        ) : (
292
                            <div key={opt} style={{marginRight: '10px'}}>
293
                                {opt}
294
                            </div>
295
                        )
296
                    )}
297
                </div>
298
            ),
299
            edit: (formApi: FormApi) => (
300
                <div>
301
                    <FormField formApi={formApi} field='spec.syncPolicy.syncOptions' component={ApplicationSyncOptionsField} />
302
                </div>
303
            )
304
        },
305
        {
306
            title: 'RETRY OPTIONS',
307
            view: <ApplicationRetryView initValues={app.spec.syncPolicy ? app.spec.syncPolicy.retry : null} />,
308
            edit: (formApi: FormApi) => (
309
                <div>
310
                    <ApplicationRetryOptions formApi={formApi} initValues={app.spec.syncPolicy ? app.spec.syncPolicy.retry : null} field='spec.syncPolicy.retry' />
311
                </div>
312
            )
313
        },
314
        {
315
            title: 'STATUS',
316
            view: (
317
                <span>
318
                    <ComparisonStatusIcon status={app.status.sync.status} /> {app.status.sync.status} {syncStatusMessage(app)}
319
                </span>
320
            )
321
        },
322
        {
323
            title: 'HEALTH',
324
            view: (
325
                <span>
326
                    <HealthStatusIcon state={app.status.health} /> {app.status.health.status}
327
                </span>
328
            )
329
        },
330
        {
331
            title: 'LINKS',
332
            view: (
333
                <DataLoader load={() => services.applications.getLinks(app.metadata.name, app.metadata.namespace)} input={app} key='appLinks'>
334
                    {(links: models.LinksResponse) => <DeepLinks links={links.items} />}
335
                </DataLoader>
336
            )
337
        }
338
    ];
339

340
    const urls = app.status.summary.externalURLs || [];
341
    if (urls.length > 0) {
342
        attributes.push({
343
            title: 'URLs',
344
            view: (
345
                <React.Fragment>
346
                    {urls
347
                        .map(item => item.split('|'))
348
                        .map((parts, i) => (
349
                            <a key={i} href={parts.length > 1 ? parts[1] : parts[0]} target='__blank'>
350
                                {parts[0]} &nbsp;
351
                            </a>
352
                        ))}
353
                </React.Fragment>
354
            )
355
        });
356
    }
357

358
    if ((app.status.summary.images || []).length) {
359
        attributes.push({
360
            title: 'IMAGES',
361
            view: (
362
                <div className='application-summary__labels'>
363
                    {(app.status.summary.images || []).sort().map(image => (
364
                        <span className='application-summary__label' key={image}>
365
                            {image}
366
                        </span>
367
                    ))}
368
                </div>
369
            )
370
        });
371
    }
372

373
    async function setAutoSync(ctx: ContextApis, confirmationTitle: string, confirmationText: string, prune: boolean, selfHeal: boolean) {
374
        const confirmed = await ctx.popup.confirm(confirmationTitle, confirmationText);
375
        if (confirmed) {
376
            try {
377
                setChangeSync(true);
378
                const updatedApp = JSON.parse(JSON.stringify(props.app)) as models.Application;
379
                if (!updatedApp.spec.syncPolicy) {
380
                    updatedApp.spec.syncPolicy = {};
381
                }
382
                updatedApp.spec.syncPolicy.automated = {prune, selfHeal};
383
                await updateApp(updatedApp, {validate: false});
384
            } catch (e) {
385
                ctx.notifications.show({
386
                    content: <ErrorNotification title={`Unable to "${confirmationTitle.replace(/\?/g, '')}:`} e={e} />,
387
                    type: NotificationType.Error
388
                });
389
            } finally {
390
                setChangeSync(false);
391
            }
392
        }
393
    }
394

395
    async function unsetAutoSync(ctx: ContextApis) {
396
        const confirmed = await ctx.popup.confirm('Disable Auto-Sync?', 'Are you sure you want to disable automated application synchronization');
397
        if (confirmed) {
398
            try {
399
                setChangeSync(true);
400
                const updatedApp = JSON.parse(JSON.stringify(props.app)) as models.Application;
401
                updatedApp.spec.syncPolicy.automated = null;
402
                await updateApp(updatedApp, {validate: false});
403
            } catch (e) {
404
                ctx.notifications.show({
405
                    content: <ErrorNotification title='Unable to disable Auto-Sync' e={e} />,
406
                    type: NotificationType.Error
407
                });
408
            } finally {
409
                setChangeSync(false);
410
            }
411
        }
412
    }
413

414
    const items = app.spec.info || [];
415
    const [adjustedCount, setAdjustedCount] = React.useState(0);
416

417
    const added = new Array<{name: string; value: string; key: string}>();
418
    for (let i = 0; i < adjustedCount; i++) {
419
        added.push({name: '', value: '', key: (items.length + i).toString()});
420
    }
421
    for (let i = 0; i > adjustedCount; i--) {
422
        items.pop();
423
    }
424
    const allItems = items.concat(added);
425
    const infoItems: EditablePanelItem[] = allItems
426
        .map((info, i) => ({
427
            key: i.toString(),
428
            title: info.name,
429
            view: info.value.match(urlPattern) ? (
430
                <a href={info.value} target='__blank'>
431
                    {info.value}
432
                </a>
433
            ) : (
434
                info.value
435
            ),
436
            titleEdit: (formApi: FormApi) => (
437
                <React.Fragment>
438
                    {i > 0 && (
439
                        <i
440
                            className='fa fa-sort-up application-summary__sort-icon'
441
                            onClick={() => {
442
                                formApi.setValue('spec.info', swap(formApi.getFormState().values.spec.info || [], i, i - 1));
443
                            }}
444
                        />
445
                    )}
446
                    <FormField formApi={formApi} field={`spec.info[${[i]}].name`} component={Text} componentProps={{style: {width: '99%'}}} />
447
                    {i < allItems.length - 1 && (
448
                        <i
449
                            className='fa fa-sort-down application-summary__sort-icon'
450
                            onClick={() => {
451
                                formApi.setValue('spec.info', swap(formApi.getFormState().values.spec.info || [], i, i + 1));
452
                            }}
453
                        />
454
                    )}
455
                </React.Fragment>
456
            ),
457
            edit: (formApi: FormApi) => (
458
                <React.Fragment>
459
                    <FormField formApi={formApi} field={`spec.info[${[i]}].value`} component={Text} />
460
                    <i
461
                        className='fa fa-times application-summary__remove-icon'
462
                        onClick={() => {
463
                            const values = (formApi.getFormState().values.spec.info || []) as Array<any>;
464
                            formApi.setValue('spec.info', [...values.slice(0, i), ...values.slice(i + 1, values.length)]);
465
                            setAdjustedCount(adjustedCount - 1);
466
                        }}
467
                    />
468
                </React.Fragment>
469
            )
470
        }))
471
        .concat({
472
            key: '-1',
473
            title: '',
474
            titleEdit: () => (
475
                <button
476
                    className='argo-button argo-button--base'
477
                    onClick={() => {
478
                        setAdjustedCount(adjustedCount + 1);
479
                    }}>
480
                    ADD NEW ITEM
481
                </button>
482
            ),
483
            view: null as any,
484
            edit: null
485
        });
486

487
    return (
488
        <div className='application-summary'>
489
            <EditablePanel
490
                save={updateApp}
491
                validate={input => ({
492
                    'spec.project': !input.spec.project && 'Project name is required',
493
                    'spec.destination.server': !input.spec.destination.server && input.spec.destination.hasOwnProperty('server') && 'Cluster server is required',
494
                    'spec.destination.name': !input.spec.destination.name && input.spec.destination.hasOwnProperty('name') && 'Cluster name is required'
495
                })}
496
                values={app}
497
                title={app.metadata.name.toLocaleUpperCase()}
498
                items={attributes}
499
                onModeSwitch={() => notificationSubscriptions.onResetNotificationSubscriptions()}
500
            />
501
            <Consumer>
502
                {ctx => (
503
                    <div className='white-box'>
504
                        <div className='white-box__details'>
505
                            <p>SYNC POLICY</p>
506
                            <div className='row white-box__details-row'>
507
                                <div className='columns small-3'>{(app.spec.syncPolicy && app.spec.syncPolicy.automated && <span>AUTOMATED</span>) || <span>NONE</span>}</div>
508
                                <div className='columns small-9'>
509
                                    {(app.spec.syncPolicy && app.spec.syncPolicy.automated && (
510
                                        <button className='argo-button argo-button--base' onClick={() => unsetAutoSync(ctx)}>
511
                                            <Spinner show={changeSync} style={{marginRight: '5px'}} />
512
                                            Disable Auto-Sync
513
                                        </button>
514
                                    )) || (
515
                                        <button
516
                                            className='argo-button argo-button--base'
517
                                            onClick={() =>
518
                                                setAutoSync(ctx, 'Enable Auto-Sync?', 'Are you sure you want to enable automated application synchronization?', false, false)
519
                                            }>
520
                                            <Spinner show={changeSync} style={{marginRight: '5px'}} />
521
                                            Enable Auto-Sync
522
                                        </button>
523
                                    )}
524
                                </div>
525
                            </div>
526

527
                            {app.spec.syncPolicy && app.spec.syncPolicy.automated && (
528
                                <React.Fragment>
529
                                    <div className='row white-box__details-row'>
530
                                        <div className='columns small-3'>PRUNE RESOURCES</div>
531
                                        <div className='columns small-9'>
532
                                            {(app.spec.syncPolicy.automated.prune && (
533
                                                <button
534
                                                    className='argo-button argo-button--base'
535
                                                    onClick={() =>
536
                                                        setAutoSync(
537
                                                            ctx,
538
                                                            'Disable Prune Resources?',
539
                                                            'Are you sure you want to disable resource pruning during automated application synchronization?',
540
                                                            false,
541
                                                            app.spec.syncPolicy.automated.selfHeal
542
                                                        )
543
                                                    }>
544
                                                    Disable
545
                                                </button>
546
                                            )) || (
547
                                                <button
548
                                                    className='argo-button argo-button--base'
549
                                                    onClick={() =>
550
                                                        setAutoSync(
551
                                                            ctx,
552
                                                            'Enable Prune Resources?',
553
                                                            'Are you sure you want to enable resource pruning during automated application synchronization?',
554
                                                            true,
555
                                                            app.spec.syncPolicy.automated.selfHeal
556
                                                        )
557
                                                    }>
558
                                                    Enable
559
                                                </button>
560
                                            )}
561
                                        </div>
562
                                    </div>
563
                                    <div className='row white-box__details-row'>
564
                                        <div className='columns small-3'>SELF HEAL</div>
565
                                        <div className='columns small-9'>
566
                                            {(app.spec.syncPolicy.automated.selfHeal && (
567
                                                <button
568
                                                    className='argo-button argo-button--base'
569
                                                    onClick={() =>
570
                                                        setAutoSync(
571
                                                            ctx,
572
                                                            'Disable Self Heal?',
573
                                                            'Are you sure you want to disable automated self healing?',
574
                                                            app.spec.syncPolicy.automated.prune,
575
                                                            false
576
                                                        )
577
                                                    }>
578
                                                    Disable
579
                                                </button>
580
                                            )) || (
581
                                                <button
582
                                                    className='argo-button argo-button--base'
583
                                                    onClick={() =>
584
                                                        setAutoSync(
585
                                                            ctx,
586
                                                            'Enable Self Heal?',
587
                                                            'Are you sure you want to enable automated self healing?',
588
                                                            app.spec.syncPolicy.automated.prune,
589
                                                            true
590
                                                        )
591
                                                    }>
592
                                                    Enable
593
                                                </button>
594
                                            )}
595
                                        </div>
596
                                    </div>
597
                                </React.Fragment>
598
                            )}
599
                        </div>
600
                    </div>
601
                )}
602
            </Consumer>
603
            <BadgePanel app={props.app.metadata.name} appNamespace={props.app.metadata.namespace} nsEnabled={useAuthSettingsCtx?.appsInAnyNamespaceEnabled} />
604
            <EditablePanel
605
                save={updateApp}
606
                values={app}
607
                title='INFO'
608
                items={infoItems}
609
                onModeSwitch={() => {
610
                    setAdjustedCount(0);
611
                    notificationSubscriptions.onResetNotificationSubscriptions();
612
                }}
613
            />
614
        </div>
615
    );
616
};
617

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

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

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

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