@spinnaker/core 0.16.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,2 @@
1
- import React from 'react';
2
- export interface IQuietPeriodBadgeProps {
3
- start: Date;
4
- end: Date;
5
- }
6
- export declare class QuietPeriodBadge extends React.Component<IQuietPeriodBadgeProps> {
7
- render(): JSX.Element;
8
- }
1
+ /// <reference types="react" />
2
+ export declare function QuietPeriodBadge(): JSX.Element;
@@ -1,15 +1,6 @@
1
- import React from 'react';
1
+ /// <reference types="react" />
2
2
  import type { IPipeline } from '../../domain/IPipeline';
3
3
  export interface ITriggersTagProps {
4
4
  pipeline: IPipeline;
5
5
  }
6
- export interface ITriggersTagState {
7
- triggerCount: number;
8
- activeTriggerCount: number;
9
- }
10
- export declare class TriggersTag extends React.Component<ITriggersTagProps, ITriggersTagState> {
11
- private quietPeriodStart;
12
- private quietPeriodEnd;
13
- constructor(props: ITriggersTagProps);
14
- render(): React.ReactElement<TriggersTag>;
15
- }
6
+ export declare function TriggersTag(props: ITriggersTagProps): JSX.Element;
@@ -0,0 +1,7 @@
1
+ interface IQuietPeriod {
2
+ currentStatus: 'UNKNOWN' | 'BEFORE_QUIET_PERIOD' | 'DURING_QUIET_PERIOD' | 'AFTER_QUIET_PERIOD' | 'NO_QUIET_PERIOD';
3
+ startTime: number;
4
+ endTime: number;
5
+ }
6
+ export declare function useQuietPeriod(): IQuietPeriod;
7
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spinnaker/core",
3
3
  "license": "Apache-2.0",
4
- "version": "0.16.0",
4
+ "version": "0.19.0",
5
5
  "module": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
7
7
  "scripts": {
@@ -120,5 +120,5 @@
120
120
  "shx": "0.3.3",
121
121
  "typescript": "4.3.5"
122
122
  },
123
- "gitHead": "59026ea6af7b81ffb8e8c59e18f43396906ddafb"
123
+ "gitHead": "9f35728cf0ae71f5b28422920e4f802d92193845"
124
124
  }
@@ -35,6 +35,9 @@ bootstrapModule.config([
35
35
  '$locationProvider',
36
36
  ($locationProvider: ILocationProvider) => {
37
37
  $locationProvider.hashPrefix('');
38
- $locationProvider.html5Mode({ enabled: false, rewriteLinks: false });
38
+ $locationProvider.html5Mode({
39
+ enabled: SETTINGS.feature.html5Routing,
40
+ rewriteLinks: false,
41
+ });
39
42
  },
40
43
  ]);
@@ -143,7 +143,6 @@ export interface ISpinnakerSettings {
143
143
  [key: string]: IProviderSettings; // allows custom providers not typed in here (good for testing too)
144
144
  };
145
145
  pubsubProviders: string[];
146
- quietPeriod: [string | number, string | number];
147
146
  resetProvider: (provider: string) => () => void;
148
147
  resetToOriginal: () => void;
149
148
  searchVersion: 1 | 2;
@@ -16,6 +16,7 @@ export interface IPipeline {
16
16
  locked?: IPipelineLock;
17
17
  limitConcurrent: boolean;
18
18
  manualStartAlert?: IPipelineManualStartAlert;
19
+ maxConcurrentExecutions?: number;
19
20
  migrationStatus?: string;
20
21
  name: string;
21
22
  notifications?: INotification[];
@@ -364,6 +364,8 @@ const helpContents: { [key: string]: string } = {
364
364
  'pipeline.config.dependsOn': 'Declares which stages must be run <em>before</em> this stage begins.',
365
365
  'pipeline.config.parallel.cancel.queue':
366
366
  '<p>If concurrent pipeline execution is disabled, then the pipelines that are in the waiting queue will get canceled when the next execution starts. <br><br>Check this box if you want to keep them in the queue.</p>',
367
+ 'pipeline.config.parallel.max.concurrent':
368
+ '<p>If concurrent pipeline execution is enabled, this variable sets the maximum number of concurrent pipelines executing. <br><br>If set to 0, then max is unlimited.</p>',
367
369
  'pipeline.config.timeout': `
368
370
  <p>Allows you to force the stage to fail if its running time exceeds a specific length.</p>
369
371
  <p><b>Note:</b> By default, Spinnaker will use sensible timeouts that depend on the stage type and the operations the stage needs to perform at runtime. These defaults can vary based on chosen configuration and other external factors.
@@ -21,4 +21,12 @@ describe('Help contents registry', () => {
21
21
  expect(HelpContentsRegistry.getHelpField('a')).toBe('b');
22
22
  });
23
23
  });
24
+
25
+ describe('max concurrent definition', () => {
26
+ it('provides the expected definition for max concurrent', () => {
27
+ const definition =
28
+ '<p>If concurrent pipeline execution is enabled, this variable sets the maximum number of concurrent pipelines executing. <br><br>If set to 0, then max is unlimited.</p>';
29
+ expect(HelpContentsRegistry.getHelpField('pipeline.config.parallel.max.concurrent')).toEqual(definition);
30
+ });
31
+ });
24
32
  });
@@ -0,0 +1,90 @@
1
+ import { mount } from 'enzyme';
2
+ import React from 'react';
3
+
4
+ import { ExecutionOptionsPageContent } from './ExecutionOptionsPageContent';
5
+ import type { IPipeline } from '../../../domain';
6
+
7
+ describe('Execution Options Page Content', () => {
8
+ describe('Max Concurrent Options', () => {
9
+ let pipeline: IPipeline;
10
+ const setPipeline = (overrides: any = {}) => {
11
+ pipeline = {
12
+ application: 'test',
13
+ id: 'test1',
14
+ keepWaitingPipelines: false,
15
+ limitConcurrent: false,
16
+ maxConcurrentExecutions: 0,
17
+ name: 'test p 1', // @ts-ignore
18
+ parameterConfig: [], // @ts-ignore
19
+ stages: [], // @ts-ignore
20
+ triggers: [],
21
+ ...overrides,
22
+ };
23
+ };
24
+ const update = (changes: any = {}) => {
25
+ pipeline = {
26
+ ...pipeline,
27
+ ...changes,
28
+ };
29
+ };
30
+ describe('enabling max concurrent', () => {
31
+ it('sets keepWaitingPipelines to true if limitConcurrent and keepWaitingPipelines are both not truthy', () => {
32
+ setPipeline();
33
+ const wrapper = mount(<ExecutionOptionsPageContent pipeline={pipeline} updatePipelineConfig={update} />);
34
+ expect(pipeline.keepWaitingPipelines).toBeFalsy();
35
+ const checkbox = wrapper.find('input').at(0);
36
+ checkbox.simulate('change', { target: { checked: true } });
37
+ expect(pipeline.keepWaitingPipelines).toBeTruthy();
38
+ });
39
+
40
+ it('does not alter pipeline if limitConcurrent is true', () => {
41
+ setPipeline({ limitConcurrent: true });
42
+ const wrapper = mount(<ExecutionOptionsPageContent pipeline={pipeline} updatePipelineConfig={update} />);
43
+ expect(pipeline.keepWaitingPipelines).toBeFalsy();
44
+ const checkbox = wrapper.find('input').at(0);
45
+ checkbox.simulate('change', { target: { checked: true } });
46
+ expect(pipeline.keepWaitingPipelines).toBeFalsy();
47
+ });
48
+
49
+ it('does not alter pipeline if keepWaitingPipelines is true', () => {
50
+ setPipeline({ keepWaitingPipelines: true });
51
+ const wrapper = mount(<ExecutionOptionsPageContent pipeline={pipeline} updatePipelineConfig={update} />);
52
+ expect(pipeline.keepWaitingPipelines).toBeTruthy();
53
+ const checkbox = wrapper.find('input').at(0);
54
+ checkbox.simulate('change', { target: { checked: true } });
55
+ expect(pipeline.keepWaitingPipelines).toBeTruthy();
56
+ });
57
+
58
+ it('defaults the max concurrent value to 0', () => {
59
+ setPipeline();
60
+ const wrapper = mount(<ExecutionOptionsPageContent pipeline={pipeline} updatePipelineConfig={update} />);
61
+ const checkbox = wrapper.find('input').at(0);
62
+ checkbox.simulate('change', { target: { checked: true } });
63
+ const concurrentInput = wrapper.find('input[type="number"]').at(0);
64
+ expect(concurrentInput.prop('value')).toEqual(0);
65
+ });
66
+ });
67
+
68
+ it('updates the max concurrent config value when the input is changed', () => {
69
+ setPipeline();
70
+ const value = 22;
71
+ const wrapper = mount(<ExecutionOptionsPageContent pipeline={pipeline} updatePipelineConfig={update} />);
72
+ const checkbox = wrapper.find('input').at(0);
73
+ checkbox.simulate('change', { target: { checked: true } });
74
+ const concurrentInput = wrapper.find('input[type="number"]').at(0);
75
+ concurrentInput.simulate('change', { target: { value } });
76
+ expect(pipeline.maxConcurrentExecutions).toEqual(value);
77
+ });
78
+
79
+ it('sets the max concurrent value to a whole number if a float is entered', () => {
80
+ setPipeline();
81
+ const value = 3.3;
82
+ const wrapper = mount(<ExecutionOptionsPageContent pipeline={pipeline} updatePipelineConfig={update} />);
83
+ const checkbox = wrapper.find('input').at(0);
84
+ checkbox.simulate('change', { target: { checked: true } });
85
+ const concurrentInput = wrapper.find('input[type="number"]').at(0);
86
+ concurrentInput.simulate('change', { target: { value } });
87
+ expect(pipeline.maxConcurrentExecutions).toEqual(3);
88
+ });
89
+ });
90
+ });
@@ -1,8 +1,8 @@
1
- import React from 'react';
1
+ import React, { useState } from 'react';
2
2
 
3
3
  import type { IPipeline } from '../../../domain';
4
4
  import { HelpField } from '../../../help';
5
- import { CheckboxInput, FormField } from '../../../presentation';
5
+ import { CheckboxInput, FormField, NumberInput } from '../../../presentation';
6
6
 
7
7
  export interface IExecutionOptionsPageContentProps {
8
8
  pipeline: IPipeline;
@@ -11,6 +11,13 @@ export interface IExecutionOptionsPageContentProps {
11
11
 
12
12
  export function ExecutionOptionsPageContent(props: IExecutionOptionsPageContentProps) {
13
13
  const { pipeline, updatePipelineConfig } = props;
14
+ const [currentMaxConcurrent, setCurrentMaxConcurrent] = useState(pipeline.maxConcurrentExecutions || 0);
15
+ const handleMaxConcurrentChange = (changeEvent: any) => {
16
+ const value = Number.parseInt(changeEvent.target.value);
17
+ setCurrentMaxConcurrent(value);
18
+ updatePipelineConfig({ maxConcurrentExecutions: value });
19
+ };
20
+
14
21
  return (
15
22
  <div className="row">
16
23
  <div className="col-md-11 col-md-offset-1">
@@ -23,6 +30,10 @@ export function ExecutionOptionsPageContent(props: IExecutionOptionsPageContentP
23
30
  )}
24
31
  onChange={() => {
25
32
  updatePipelineConfig({ limitConcurrent: !pipeline.limitConcurrent });
33
+ if (!pipeline.limitConcurrent && !pipeline.keepWaitingPipelines) {
34
+ pipeline.keepWaitingPipelines = true;
35
+ updatePipelineConfig({ keepWaitingPipelines: true });
36
+ }
26
37
  }}
27
38
  value={pipeline.limitConcurrent}
28
39
  />
@@ -45,6 +56,20 @@ export function ExecutionOptionsPageContent(props: IExecutionOptionsPageContentP
45
56
  value={pipeline.keepWaitingPipelines}
46
57
  />
47
58
  )}
59
+ {!pipeline.limitConcurrent && (
60
+ <div>
61
+ <label className="col-md-3 sm-label-right">
62
+ Maximum concurrent pipeline executions <HelpField id={'pipeline.config.parallel.max.concurrent'} />
63
+ </label>
64
+ <div className="col-md-8">
65
+ <FormField
66
+ input={(inputProps) => <NumberInput {...inputProps} min={0} max={65534} />}
67
+ onChange={handleMaxConcurrentChange}
68
+ value={currentMaxConcurrent}
69
+ />
70
+ </div>
71
+ </div>
72
+ )}
48
73
  </div>
49
74
  </div>
50
75
  );
@@ -71,18 +71,33 @@ export class StageFailureMessage extends React.Component<IStageFailureMessagePro
71
71
  }
72
72
 
73
73
  public render() {
74
- const { message, messages } = this.props;
74
+ const { message, messages, stage } = this.props;
75
75
  const { isFailed, failedTask, failedExecutionId, failedStageName, failedStageId } = this.state;
76
- if (isFailed || failedTask || message || messages.length) {
76
+
77
+ let stageMessages = message && !messages.length ? [message] : messages;
78
+ if (stageMessages.length > 0) {
77
79
  const exceptionTitle = isFailed ? (messages.length ? 'Exceptions' : 'Exception') : 'Warning';
78
- const displayMessages =
79
- message || !messages.length ? (
80
- <Markdown message={message || StageFailureMessages.NO_REASON_PROVIDED} className="break-word" />
81
- ) : (
82
- messages.map((m, i) => (
83
- <Markdown key={i} message={m || StageFailureMessages.NO_REASON_PROVIDED} className="break-word" />
84
- ))
85
- );
80
+
81
+ // expression evaluation warnings can get really long and hide actual failure messages, source
82
+ // filter out expression evaluation failure messages if either:
83
+ // - there was a stage failure (and failed expressions don't fail the stage)
84
+ // - expression evaluation was explicitly disabled for the stage(as Orca still processes expressions and populates
85
+ // warnings when evaluation is disabled disabled)
86
+ const shouldFilterExpressionFailures =
87
+ (isFailed && !stage.context?.failOnFailedExpressions) || stage.context?.skipExpressionEvaluation;
88
+
89
+ if (shouldFilterExpressionFailures) {
90
+ stageMessages = stageMessages.filter((m) => !m.startsWith('Failed to evaluate'));
91
+
92
+ if (stageMessages.length === 0) {
93
+ // no messages to be displayed after filtering
94
+ return null;
95
+ }
96
+ }
97
+
98
+ const displayMessages = stageMessages.map((m, i) => (
99
+ <Markdown key={i} message={m || StageFailureMessages.NO_REASON_PROVIDED} className="break-word" />
100
+ ));
86
101
 
87
102
  if (displayMessages) {
88
103
  return (
@@ -2,11 +2,7 @@ import type { DateTimeFormatOptions } from 'luxon';
2
2
  import React from 'react';
3
3
 
4
4
  import { Tooltip } from '../../presentation';
5
-
6
- export interface IQuietPeriodBadgeProps {
7
- start: Date;
8
- end: Date;
9
- }
5
+ import { useQuietPeriod } from './useQuietPeriod.hook';
10
6
 
11
7
  const locale = 'en-US';
12
8
  const dateOptions: DateTimeFormatOptions = {
@@ -18,37 +14,30 @@ const dateOptions: DateTimeFormatOptions = {
18
14
  minute: '2-digit',
19
15
  };
20
16
 
21
- export class QuietPeriodBadge extends React.Component<IQuietPeriodBadgeProps> {
22
- public render() {
23
- const { start, end } = this.props;
24
-
25
- if (!start || !end) {
26
- return null;
27
- }
17
+ export function QuietPeriodBadge() {
18
+ const quietPeriod = useQuietPeriod();
19
+ if (quietPeriod.currentStatus === 'NO_QUIET_PERIOD' || quietPeriod.currentStatus === 'UNKNOWN') {
20
+ return null;
21
+ }
28
22
 
29
- const now = new Date();
30
- const afterQuietPeriod = now > end;
31
- if (afterQuietPeriod) {
32
- return null;
33
- }
23
+ const start = new Date(quietPeriod.startTime);
24
+ const end = new Date(quietPeriod.endTime);
34
25
 
35
- const inQuietPeriod = start < now && !afterQuietPeriod;
26
+ const message =
27
+ quietPeriod.currentStatus === 'DURING_QUIET_PERIOD'
28
+ ? 'This pipeline will not be automatically triggered until the end of the quiet period. '
29
+ : 'This pipeline will not be automatically triggered during the quiet period. ';
36
30
 
37
- const quietPeriodRange = (
31
+ const template = (
32
+ <span>
33
+ {message}
38
34
  <span>{`(${start.toLocaleString(locale, dateOptions)} - ${end.toLocaleString(locale, dateOptions)})`}</span>
39
- );
40
- const tooltipTemplate = inQuietPeriod ? (
41
- <span>
42
- This pipeline will not be automatically triggered until the end of the quiet period. {quietPeriodRange}
43
- </span>
44
- ) : (
45
- <span>This pipeline will not be automatically triggered during the quiet period. {quietPeriodRange}</span>
46
- );
47
-
48
- return (
49
- <Tooltip template={tooltipTemplate}>
50
- <i className="fa icon-calendar-warning" style={{ color: 'red' }} />
51
- </Tooltip>
52
- );
53
- }
35
+ </span>
36
+ );
37
+
38
+ return (
39
+ <Tooltip template={template}>
40
+ <i className="fa icon-calendar-warning" style={{ color: 'red' }} />
41
+ </Tooltip>
42
+ );
54
43
  }
@@ -1,79 +1,49 @@
1
- import { filter } from 'lodash';
2
- import React from 'react';
1
+ import * as React from 'react';
3
2
 
4
3
  import { QuietPeriodBadge } from './QuietPeriodBadge';
5
- import { SETTINGS } from '../../config';
4
+ import type { ITrigger } from '../../domain';
6
5
  import type { IPipeline } from '../../domain/IPipeline';
6
+ import { useQuietPeriod } from './useQuietPeriod.hook';
7
7
 
8
8
  export interface ITriggersTagProps {
9
9
  pipeline: IPipeline;
10
10
  }
11
11
 
12
- export interface ITriggersTagState {
13
- triggerCount: number;
14
- activeTriggerCount: number;
15
- }
16
-
17
- export class TriggersTag extends React.Component<ITriggersTagProps, ITriggersTagState> {
18
- private quietPeriodStart: Date;
19
- private quietPeriodEnd: Date;
20
-
21
- constructor(props: ITriggersTagProps) {
22
- super(props);
23
-
24
- let triggerCount = 0;
25
- let activeTriggerCount = 0;
26
- let quietPeriodEnabled = false;
27
-
28
- const pipeline = this.props.pipeline;
29
- if (pipeline && pipeline.triggers && pipeline.triggers.length) {
30
- triggerCount = pipeline.triggers.length;
31
- activeTriggerCount = filter(pipeline.triggers, { enabled: true }).length;
32
- quietPeriodEnabled = Boolean(pipeline.respectQuietPeriod);
33
- }
34
-
35
- const hasQuietPeriod = SETTINGS.feature.quietPeriod && SETTINGS.quietPeriod && SETTINGS.quietPeriod.length === 2;
36
-
37
- if (hasQuietPeriod && quietPeriodEnabled) {
38
- this.quietPeriodStart = new Date(SETTINGS.quietPeriod[0]);
39
- this.quietPeriodEnd = new Date(SETTINGS.quietPeriod[1]);
40
- }
41
-
42
- this.state = {
43
- triggerCount,
44
- activeTriggerCount,
45
- };
12
+ function getTriggerLabel(totalTriggers: number, activeTriggers: number) {
13
+ if (totalTriggers === 1) {
14
+ return `Trigger: ${activeTriggers ? 'enabled' : 'disabled'}`;
15
+ } else if (totalTriggers === activeTriggers) {
16
+ return `All triggers: enabled`;
17
+ } else if (activeTriggers === 0) {
18
+ return `All triggers: disabled`;
19
+ } else {
20
+ return `Some triggers: enabled`;
46
21
  }
22
+ }
47
23
 
48
- public render(): React.ReactElement<TriggersTag> {
49
- const { pipeline } = this.props;
50
- const { triggerCount, activeTriggerCount } = this.state;
51
-
52
- if (triggerCount > 0) {
53
- const now = new Date();
54
- const inQuietPeriod = this.quietPeriodStart < now && now < this.quietPeriodEnd;
24
+ export function TriggersTag(props: ITriggersTagProps) {
25
+ const quietPeriod = useQuietPeriod();
55
26
 
56
- const triggers =
57
- triggerCount === 1
58
- ? 'Trigger'
59
- : activeTriggerCount === triggerCount || inQuietPeriod
60
- ? 'All triggers'
61
- : 'Some triggers';
62
- const displayTriggers = `${triggers}: ${activeTriggerCount === 0 || inQuietPeriod ? 'disabled' : 'enabled'}`;
27
+ const { pipeline } = props;
28
+ const triggers = pipeline?.triggers ?? [];
29
+ const isTriggerDisabled = (t: ITrigger) =>
30
+ !t.enabled ||
31
+ (pipeline.respectQuietPeriod && quietPeriod.currentStatus === 'DURING_QUIET_PERIOD' && t.type !== 'pipeline');
32
+ const activeTriggers = triggers.filter((t: ITrigger) => !isTriggerDisabled(t));
63
33
 
64
- return (
65
- <div
66
- className={`triggers-toggle ${activeTriggerCount ? '' : 'disabled'}`}
67
- style={{ visibility: pipeline.disabled ? 'hidden' : 'visible' }}
68
- >
69
- <span>
70
- <span>
71
- <QuietPeriodBadge start={this.quietPeriodStart} end={this.quietPeriodEnd} /> {displayTriggers}
72
- </span>
73
- </span>
74
- </div>
75
- );
76
- }
34
+ if (triggers.length === 0) {
77
35
  return <div />;
78
36
  }
37
+
38
+ return (
39
+ <div
40
+ className={`triggers-toggle ${activeTriggers.length > 0 ? '' : 'disabled'}`}
41
+ style={{ visibility: pipeline.disabled ? 'hidden' : 'visible' }}
42
+ >
43
+ <span className="flex-container-h margin-between-sm baseline">
44
+ {pipeline.respectQuietPeriod && <QuietPeriodBadge />}
45
+ <span>{getTriggerLabel(triggers.length, activeTriggers.length)}</span>
46
+ </span>
47
+ </div>
48
+ );
79
49
  }
@@ -0,0 +1,43 @@
1
+ import { REST } from '../../api';
2
+ import { useLatestPromise } from '../../presentation';
3
+
4
+ // Shape from back end
5
+ interface IQuietPeriodConfig {
6
+ startTime: number;
7
+ endTime: number;
8
+ enabled: boolean;
9
+ // Do not use in UI -- point-in-time data from back-end
10
+ inQuietPeriod: boolean;
11
+ }
12
+
13
+ class QuietPeriodService {
14
+ private static _quietPeriodConfig: PromiseLike<IQuietPeriodConfig>;
15
+ static async quietPeriodConfig(): Promise<IQuietPeriodConfig> {
16
+ this._quietPeriodConfig = this._quietPeriodConfig ?? REST('/capabilities/quietPeriod').get<IQuietPeriodConfig>();
17
+ return await this._quietPeriodConfig;
18
+ }
19
+ }
20
+
21
+ interface IQuietPeriod {
22
+ currentStatus: 'UNKNOWN' | 'BEFORE_QUIET_PERIOD' | 'DURING_QUIET_PERIOD' | 'AFTER_QUIET_PERIOD' | 'NO_QUIET_PERIOD';
23
+ startTime: number;
24
+ endTime: number;
25
+ }
26
+
27
+ export function useQuietPeriod(): IQuietPeriod {
28
+ const result = useLatestPromise(() => QuietPeriodService.quietPeriodConfig(), []);
29
+ if (result.status !== 'RESOLVED') {
30
+ return { currentStatus: 'UNKNOWN', startTime: undefined, endTime: undefined };
31
+ }
32
+
33
+ const { startTime, endTime, enabled } = result.result;
34
+ if (!enabled || !startTime || startTime < 0 || !endTime || endTime < 0) {
35
+ return { currentStatus: 'NO_QUIET_PERIOD', startTime: undefined, endTime: undefined };
36
+ }
37
+
38
+ const now = Date.now();
39
+ const currentStatus =
40
+ now < startTime ? 'BEFORE_QUIET_PERIOD' : now > endTime ? 'AFTER_QUIET_PERIOD' : 'DURING_QUIET_PERIOD';
41
+
42
+ return { currentStatus, startTime, endTime };
43
+ }