@spinnaker/core 2025.0.6 → 2025.1.1

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.
Files changed (116) hide show
  1. package/dist/api/ApiService.d.ts +2 -2
  2. package/dist/api/mock/mockHttpUtils.d.ts +2 -2
  3. package/dist/application/listExtractor/AppListExtractor.d.ts +2 -2
  4. package/dist/bootstrap/paramChangedHelper.d.ts +1 -1
  5. package/dist/cloudProvider/providerSelection/ProviderSelectionService.d.ts +1 -1
  6. package/dist/cluster/filter/ClusterFilterService.d.ts +1 -1
  7. package/dist/cluster/task.matcher.d.ts +1 -1
  8. package/dist/domain/IManagedEntity.d.ts +8 -8
  9. package/dist/domain/IServerGroup.d.ts +1 -0
  10. package/dist/domain/IStageTypeConfig.d.ts +1 -1
  11. package/dist/entityTag/notifications/NotificationsPopover.d.ts +1 -1
  12. package/dist/image/image.reader.d.ts +2 -2
  13. package/dist/index.js +1726 -1521
  14. package/dist/index.js.map +1 -1
  15. package/dist/managed/config/Configuration.d.ts +1 -1
  16. package/dist/managed/config/GitIntegration.d.ts +1 -1
  17. package/dist/managed/constraints/registry.d.ts +2 -2
  18. package/dist/managed/graphql/graphql-sdk.d.ts +137 -137
  19. package/dist/managed/managed.states.d.ts +1 -1
  20. package/dist/managed/overview/artifact/ArtifactActionModal.d.ts +1 -1
  21. package/dist/managed/overview/artifact/VersionOperation.d.ts +3 -3
  22. package/dist/managed/overview/artifact/utils.d.ts +2 -2
  23. package/dist/managed/overview/types.d.ts +9 -9
  24. package/dist/managed/resourceHistory/ManagedResourceHistoryModal.d.ts +1 -1
  25. package/dist/managed/resources/ResourceDefinitionModal.d.ts +1 -1
  26. package/dist/managed/resources/resourceRegistry.d.ts +1 -1
  27. package/dist/managed/versionMetadata/MetadataComponents.d.ts +1 -1
  28. package/dist/managed/versionsHistory/types.d.ts +6 -6
  29. package/dist/manifest/ManifestYaml.d.ts +1 -1
  30. package/dist/modal/wizard/WizardPage.d.ts +1 -1
  31. package/dist/navigation/urlParser.d.ts +1 -1
  32. package/dist/pagerDuty/Pager.d.ts +1 -1
  33. package/dist/pipeline/config/actions/pipelineJson/EditPipelineJsonModal.d.ts +1 -1
  34. package/dist/pipeline/config/stages/FormikStageConfig.d.ts +2 -2
  35. package/dist/pipeline/config/stages/bakeManifest/helm/BakeHelmConfigForm.d.ts +1 -0
  36. package/dist/pipeline/config/stages/common/ExecutionDetailsSection.d.ts +1 -1
  37. package/dist/pipeline/config/stages/entityTags/TagEditor.d.ts +1 -1
  38. package/dist/pipeline/config/templates/PipelineTemplateReader.d.ts +1 -1
  39. package/dist/pipeline/config/triggers/artifacts/helm-image/HelmImageArtifactEditor.d.ts +3 -0
  40. package/dist/pipeline/config/validation/anyFieldRequired.validator.d.ts +1 -1
  41. package/dist/pipeline/config/validation/requiredField.validator.d.ts +1 -1
  42. package/dist/plugins/plugin.registry.d.ts +1 -1
  43. package/dist/presentation/Placement.d.ts +1 -1
  44. package/dist/presentation/Popover.d.ts +1 -1
  45. package/dist/presentation/details/Details.d.ts +7 -0
  46. package/dist/presentation/forms/fields/FormField.d.ts +1 -1
  47. package/dist/presentation/forms/fields/FormikExpressionField.d.ts +1 -1
  48. package/dist/presentation/forms/fields/FormikExpressionRegexField.d.ts +1 -1
  49. package/dist/presentation/forms/fields/FormikFormField.d.ts +1 -1
  50. package/dist/presentation/forms/inputs/interface.d.ts +2 -2
  51. package/dist/presentation/forms/validation/categories.d.ts +3 -3
  52. package/dist/presentation/forms/validation/validation.d.ts +3 -3
  53. package/dist/presentation/hooks/useLatestPromise.hook.d.ts +1 -1
  54. package/dist/presentation/modal/showModal.d.ts +1 -1
  55. package/dist/presentation/tables/Table.d.ts +1 -1
  56. package/dist/presentation/tables/TableCell.d.ts +1 -1
  57. package/dist/presentation/tables/TableRow.d.ts +1 -1
  58. package/dist/presentation/tables/standardGridTableLayout.d.ts +1 -1
  59. package/dist/projects/Projects.d.ts +2 -0
  60. package/dist/projects/index.d.ts +1 -0
  61. package/dist/projects/projects.module.d.ts +3 -2
  62. package/dist/reactShims/AngularJSAdapter.d.ts +3 -3
  63. package/dist/search/infrastructure/SearchResultPods.d.ts +1 -1
  64. package/dist/search/infrastructure/infrastructureSearch.service.d.ts +1 -1
  65. package/dist/serverGroup/details/ServerGroupDetailsWrapper.d.ts +1 -1
  66. package/dist/serverGroupManager/index.d.ts +1 -0
  67. package/dist/serverGroupManager/serverGroupManager.states.d.ts +1 -1
  68. package/dist/utils/Logger.d.ts +1 -1
  69. package/dist/utils/feature/Feature.d.ts +58 -0
  70. package/dist/utils/feature/FeatureContext.d.ts +24 -0
  71. package/dist/utils/feature/index.d.ts +3 -0
  72. package/dist/utils/feature/useFeature.hook.d.ts +15 -0
  73. package/dist/utils/index.d.ts +2 -0
  74. package/dist/utils/json/traverseObject.d.ts +1 -1
  75. package/dist/utils/parseNum.d.ts +1 -0
  76. package/dist/utils/testUtils/index.d.ts +7 -0
  77. package/dist/utils/workerPool.d.ts +1 -1
  78. package/package.json +3 -3
  79. package/src/artifact/ArtifactIconService.ts +1 -0
  80. package/src/artifact/ArtifactTypes.ts +1 -0
  81. package/src/artifact/ExpectedArtifactSelectorViewController.ts +1 -1
  82. package/src/config/VersionChecker.tsx +1 -1
  83. package/src/domain/IServerGroup.ts +1 -0
  84. package/src/help/help.contents.ts +1 -1
  85. package/src/navigation/UrlBuilder.ts +15 -0
  86. package/src/notification/NotificationsList.tsx +12 -8
  87. package/src/pipeline/config/stages/bakeManifest/helm/BakeHelmConfigForm.tsx +8 -2
  88. package/src/pipeline/config/triggers/artifacts/helm-image/HelmImageArtifactEditor.tsx +169 -0
  89. package/src/pipeline/config/triggers/artifacts/index.ts +3 -0
  90. package/src/pipeline/config/validation/PipelineConfigValidator.ts +2 -2
  91. package/src/pipeline/executions/executionGroup/ExecutionGroups.tsx +37 -2
  92. package/src/pipeline/filter/executionFilter.service.ts +50 -5
  93. package/src/presentation/details/Details.tsx +18 -1
  94. package/src/presentation/forms/FormikForm.tsx +1 -1
  95. package/src/projects/ProjectHeader.tsx +12 -9
  96. package/src/projects/Projects.spec.tsx +141 -0
  97. package/src/projects/Projects.tsx +148 -0
  98. package/src/projects/index.ts +1 -0
  99. package/src/projects/{projects.module.js → projects.module.ts} +0 -2
  100. package/src/projects/projects.states.ts +4 -6
  101. package/src/serverGroup/details/ServerGroupDetails.tsx +1 -1
  102. package/src/serverGroupManager/ServerGroupManager.tsx +2 -0
  103. package/src/serverGroupManager/ServerGroupManagerTag.tsx +1 -1
  104. package/src/serverGroupManager/index.ts +1 -0
  105. package/src/serverGroupManager/serverGroupManager.states.ts +3 -3
  106. package/src/utils/feature/Feature.tsx +98 -0
  107. package/src/utils/feature/FeatureContext.tsx +49 -0
  108. package/src/utils/feature/index.ts +3 -0
  109. package/src/utils/feature/useFeature.hook.tsx +25 -0
  110. package/src/utils/index.ts +2 -0
  111. package/src/utils/parseNum.ts +2 -0
  112. package/src/utils/testUtils/index.tsx +30 -0
  113. package/dist/projects/projects.controller.d.ts +0 -2
  114. package/src/projects/projects.controller.js +0 -112
  115. package/src/projects/projects.controller.spec.js +0 -86
  116. package/src/projects/projects.html +0 -87
@@ -0,0 +1,24 @@
1
+ import * as React from 'react';
2
+ import type { IFeatures } from '../../config';
3
+ /**
4
+ * Type alias for feature flags, extending from IFeatures interface.
5
+ */
6
+ export type FeatureFlags = IFeatures;
7
+ /**
8
+ * Creates a React Context for feature flags.
9
+ * Defaults to `SETTINGS.feature` if no provider is found in the component tree.
10
+ */
11
+ export declare const FeatureFlagsContext: React.Context<IFeatures>;
12
+ /**
13
+ * A context provider component that merges the global feature flags
14
+ * from `SETTINGS.feature` with any feature flags passed in via props.
15
+ *
16
+ * @param {Object} props
17
+ * @param {FeatureFlags} [props.features] - Feature flags to merge with the global flags.
18
+ * @param {React.ReactNode} props.children - Components that will consume the merged flags.
19
+ * @returns {JSX.Element} - The provider that holds merged feature flags in context.
20
+ */
21
+ export declare function FeaturesProvider({ features, children, }: {
22
+ features?: FeatureFlags;
23
+ children: React.ReactNode;
24
+ }): JSX.Element;
@@ -0,0 +1,3 @@
1
+ export * from './Feature';
2
+ export * from './FeatureContext';
3
+ export * from './useFeature.hook';
@@ -0,0 +1,15 @@
1
+ import type { FeatureFlags } from './FeatureContext';
2
+ /**
3
+ * A hook to access the current set of feature flags from the context.
4
+ * If no provider is found, it will default to `SETTINGS.feature`.
5
+ *
6
+ * @returns {FeatureFlags} - The feature flags currently in the context.
7
+ */
8
+ export declare function useFeatures(): FeatureFlags;
9
+ /**
10
+ * A hook to determine if a particular feature is enabled.
11
+ *
12
+ * @param {keyof FeatureFlags} feature - The name of the feature flag to check.
13
+ * @returns {boolean} - True if the feature is enabled; otherwise false.
14
+ */
15
+ export declare function useFeature(feature: keyof FeatureFlags): boolean;
@@ -12,6 +12,7 @@ export * from './json/traverseObject';
12
12
  export * from './noop';
13
13
  export * from './q';
14
14
  export * from './renderIfFeature.component';
15
+ export * from './feature';
15
16
  export * from './scrollTo/scrollTo.service';
16
17
  export * from './timeFormatters';
17
18
  export * from './unicodeBase64';
@@ -19,3 +20,4 @@ export * from './uuid.service';
19
20
  export * from './workerPool';
20
21
  export * from './json/filterObjectValues';
21
22
  export * from './Logger';
23
+ export * from './parseNum';
@@ -10,5 +10,5 @@
10
10
  * i.e., properties that are neither a nested object, nor an array.
11
11
  */
12
12
  export declare const traverseObject: (object: object, callback: ITraverseCallback, traverseLeafNodesOnly?: boolean) => void;
13
- declare type ITraverseCallback = (path: string, obj: object) => void;
13
+ type ITraverseCallback = (path: string, obj: object) => void;
14
14
  export {};
@@ -0,0 +1 @@
1
+ export declare const parseNum: (numOrStr: number | string) => number;
@@ -0,0 +1,7 @@
1
+ import * as React from 'react';
2
+ /**
3
+ * Create a minimal UIRouter environment so components can render RouteLinks, etc.
4
+ */
5
+ export declare const wrapWithRouter: (node: React.ReactElement) => JSX.Element;
6
+ /** Helper to DRY mounting + async flush + update. */
7
+ export declare function mountAndFlush(element: React.ReactElement): Promise<import("enzyme").ReactWrapper<any, Readonly<{}>, React.Component<{}, {}, any>>>;
@@ -1,4 +1,4 @@
1
- export declare type Task<T = any> = () => Promise<T>;
1
+ export type Task<T = any> = () => Promise<T>;
2
2
  export declare class WorkerPool<T = any> {
3
3
  private concurrency;
4
4
  private queuedTasks;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spinnaker/core",
3
3
  "license": "Apache-2.0",
4
- "version": "2025.0.6",
4
+ "version": "2025.1.1",
5
5
  "module": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
7
7
  "publishConfig": {
@@ -122,7 +122,7 @@
122
122
  "graphql": "^15.5.1",
123
123
  "rollup-plugin-copy": "^3.4.0",
124
124
  "shx": "0.3.3",
125
- "typescript": "4.3.5"
125
+ "typescript": "5.0.4"
126
126
  },
127
- "gitHead": "9f1607115a075e95b75864f2304adb6c67157e86"
127
+ "gitHead": "69afffd9c232790ea4ae706269583e6f375610dc"
128
128
  }
@@ -57,3 +57,4 @@ ArtifactIconService.registerType(ArtifactTypePatterns.JENKINS_FILE, jenkinsFileI
57
57
  ArtifactIconService.registerType(ArtifactTypePatterns.MAVEN_FILE, mavenFileIcon);
58
58
  ArtifactIconService.registerType(ArtifactTypePatterns.HTTP_FILE, httpFileIcon);
59
59
  ArtifactIconService.registerType(ArtifactTypePatterns.ORACLE_OBJECT, oracleObjectIcon);
60
+ ArtifactIconService.registerType(ArtifactTypePatterns.HELM_IMAGE, helmChartIcon);
@@ -22,6 +22,7 @@ export const ArtifactTypePatterns: IArtifactTypePatterns = {
22
22
  HTTP_FILE: /http\/file/,
23
23
  FRONT50_PIPELINE_TEMPLATE: /front50\/pipelineTemplate/,
24
24
  ORACLE_OBJECT: /oracle\/object/,
25
+ HELM_IMAGE: /helm\/image/,
25
26
  };
26
27
 
27
28
  export const excludeAllTypesExcept = (...types: RegExp[]) =>
@@ -31,7 +31,7 @@ export class ExpectedArtifactSelectorViewController {
31
31
  const artifact = ExpectedArtifactService.artifactFromExpected(expectedArtifact);
32
32
  const allAccounts = this.delegate.getExpectedArtifactAccounts();
33
33
  this.accountsForArtifact =
34
- artifact.type === 'helm/chart'
34
+ artifact.type === 'helm/chart' || artifact.type === 'helm/image'
35
35
  ? allAccounts.filter((a) => a.types.includes(artifact.type) && a.name === artifact.artifactAccount)
36
36
  : allAccounts.filter((a) => a.types.includes(artifact.type));
37
37
  const selected = this.delegate.getSelectedAccount();
@@ -46,7 +46,7 @@ export class VersionChecker {
46
46
  content: (
47
47
  <div>
48
48
  A new version of Spinnaker is available{' '}
49
- <a role="button" className="action" onClick={() => document.location.reload(true)}>
49
+ <a role="button" className="action" onClick={() => document.location.reload()}>
50
50
  Refresh
51
51
  </a>
52
52
  </div>
@@ -16,6 +16,7 @@ export interface IAsg {
16
16
  }
17
17
 
18
18
  export interface IServerGroup extends IManagedResource {
19
+ [key: string]: any;
19
20
  account: string;
20
21
  app?: string;
21
22
  asg?: IAsg;
@@ -303,7 +303,7 @@ const helpContents: { [key: string]: string } = {
303
303
  '<p>Explicitly evaluate SpEL expressions in overrides just prior to manifest baking. Can be paired with the "Skip SpEL evaluation" option in the Deploy Manifest stage when baking a third-party manifest artifact with expressions not meant for Spinnaker to evaluate as SpEL.</p>',
304
304
  'pipeline.config.bake.manifest.templateRenderer': '<p>This is the engine used for rendering your manifest.</p>',
305
305
  'pipeline.config.bake.manifest.helm.chartFilePath': `
306
- <p>This is the relative path to the directory containing the Chart.yaml file within your Git repo.</p>
306
+ <p>This is the relative path to the directory containing the Chart.yaml file within your Git repo (or helm/image artifact).</p>
307
307
  <p>e.g.: <b>helm/my-chart</b></p>`,
308
308
  'pipeline.config.bake.manifest.helm.rawOverrides':
309
309
  'Use <i>--set</i> instead of <i>--set-string</i> when injecting override values. Values injected using <i>--set</i> will be converted to primitive types by Helm.',
@@ -244,6 +244,21 @@ class ServerGroupsUrlBuilder implements IUrlBuilder {
244
244
  { inherit: false },
245
245
  );
246
246
 
247
+ if (
248
+ input.provider === 'ecs' ||
249
+ (input.provider && input.provider.includes('ecs')) ||
250
+ input.provider === 'aws' ||
251
+ (input.provider && input.provider.includes('aws'))
252
+ ) {
253
+ const serverGroupParts = input.serverGroup.split('-');
254
+ let clusterName = input.serverGroup;
255
+ if (serverGroupParts.length > 1 && serverGroupParts[serverGroupParts.length - 1].match(/^v\d+$/)) {
256
+ clusterName = serverGroupParts.slice(0, -1).join('-');
257
+ }
258
+
259
+ return UrlBuilderUtils.buildUrl(href, { clusters: `${input.account}:${clusterName}` });
260
+ }
261
+
247
262
  return UrlBuilderUtils.buildUrl(href, { q: input.serverGroup, acct: input.account, reg: input.region });
248
263
  }
249
264
  }
@@ -1,5 +1,5 @@
1
1
  import classNames from 'classnames';
2
- import { capitalize, filter, flatten, get, isEmpty } from 'lodash';
2
+ import { capitalize, get, isEmpty } from 'lodash';
3
3
  import React from 'react';
4
4
  import { from as observableFrom, Subject } from 'rxjs';
5
5
  import { takeUntil } from 'rxjs/operators';
@@ -154,15 +154,19 @@ export class NotificationsList extends React.Component<INotificationsListProps,
154
154
  observableFrom(AppNotificationsService.getNotificationsForApplication(application.name))
155
155
  .pipe(takeUntil(this.destroy$))
156
156
  .subscribe((notifications) => {
157
- const results = filter(
158
- flatten(
159
- supportedNotificationTypes.map((type) => {
160
- return get(notifications, type) || [];
161
- }),
162
- ),
157
+ const newNotifications = supportedNotificationTypes.reduce<INotification[]>((acc, type) => {
158
+ const typeNotifications = notifications[type];
159
+ if (typeNotifications && typeof typeNotifications !== 'string') {
160
+ acc.push(...typeNotifications);
161
+ }
162
+ return acc;
163
+ }, []);
164
+
165
+ const applicationNotifications = newNotifications.filter(
163
166
  (allow) => allow !== undefined && allow.level === 'application',
164
167
  );
165
- updateNotifications(results);
168
+
169
+ updateNotifications(applicationNotifications);
166
170
  });
167
171
  this.setState({ isNotificationsDirty: false });
168
172
  };
@@ -17,12 +17,13 @@ import { CheckboxInput, TextInput } from '../../../../../presentation';
17
17
 
18
18
  export interface IBakeHelmConfigFormState {
19
19
  gitRepoArtifactAccountNames: string[];
20
+ helmImageArtifactAccountNames: string[];
20
21
  }
21
22
 
22
23
  export class BakeHelmConfigForm extends React.Component<IFormikStageConfigInjectedProps, IBakeHelmConfigFormState> {
23
24
  constructor(props: IFormikStageConfigInjectedProps) {
24
25
  super(props);
25
- this.state = { gitRepoArtifactAccountNames: [] };
26
+ this.state = { gitRepoArtifactAccountNames: [], helmImageArtifactAccountNames: [] };
26
27
  }
27
28
 
28
29
  private static readonly excludedArtifactTypes = excludeAllTypesExcept(
@@ -37,6 +38,7 @@ export class BakeHelmConfigForm extends React.Component<IFormikStageConfigInject
37
38
  ArtifactTypePatterns.HELM_CHART,
38
39
  ArtifactTypePatterns.HTTP_FILE,
39
40
  ArtifactTypePatterns.ORACLE_OBJECT,
41
+ ArtifactTypePatterns.HELM_IMAGE,
40
42
  );
41
43
 
42
44
  public componentDidMount() {
@@ -73,6 +75,9 @@ export class BakeHelmConfigForm extends React.Component<IFormikStageConfigInject
73
75
  gitRepoArtifactAccountNames: artifactAccounts
74
76
  .filter((account) => account.types.some((type) => ArtifactTypePatterns.GIT_REPO.test(type)))
75
77
  .map((account) => account.name),
78
+ helmImageArtifactAccountNames: artifactAccounts
79
+ .filter((account) => account.types.some((type) => ArtifactTypePatterns.HELM_IMAGE.test(type)))
80
+ .map((account) => account.name),
76
81
  });
77
82
  });
78
83
  }
@@ -208,7 +213,8 @@ export class BakeHelmConfigForm extends React.Component<IFormikStageConfigInject
208
213
  pipeline={this.props.pipeline}
209
214
  stage={stage}
210
215
  />
211
- {this.state.gitRepoArtifactAccountNames.includes(this.getInputArtifact(stage, 0).account) && (
216
+ {(this.state.gitRepoArtifactAccountNames.includes(this.getInputArtifact(stage, 0).account) ||
217
+ this.state.helmImageArtifactAccountNames.includes(this.getInputArtifact(stage, 0).account)) && (
212
218
  <StageConfigField label="Helm Chart File Path" helpKey="pipeline.config.bake.manifest.helm.chartFilePath">
213
219
  <TextInput
214
220
  onChange={(e: React.ChangeEvent<any>) => {
@@ -0,0 +1,169 @@
1
+ import { cloneDeep } from 'lodash';
2
+ import React from 'react';
3
+ import type { Option } from 'react-select';
4
+
5
+ import { ArtifactService } from '../ArtifactService';
6
+ import { ArtifactTypePatterns } from '../../../../../artifact/ArtifactTypes';
7
+ import type { IArtifact, IArtifactEditorProps, IArtifactKindConfig } from '../../../../../domain';
8
+ import { TetheredCreatable, TetheredSelect } from '../../../../../presentation';
9
+ import { StageConfigField } from '../../../stages/common';
10
+ import { Spinner } from '../../../../../widgets/spinners/Spinner';
11
+
12
+ const TYPE = 'helm/image';
13
+
14
+ interface IHelmImageArtifactEditorState {
15
+ names: string[];
16
+ versions: Array<Option<string>>;
17
+ versionsLoading: boolean;
18
+ namesLoading: boolean;
19
+ }
20
+
21
+ class HelmImageEditor extends React.Component<IArtifactEditorProps, IHelmImageArtifactEditorState> {
22
+ public state: IHelmImageArtifactEditorState = {
23
+ names: [],
24
+ versions: [],
25
+ versionsLoading: true,
26
+ namesLoading: true,
27
+ };
28
+
29
+ // taken from https://github.com/semver/semver/issues/232
30
+ private SEMVER = new RegExp(
31
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$/,
32
+ );
33
+
34
+ constructor(props: IArtifactEditorProps) {
35
+ super(props);
36
+ const { artifact } = this.props;
37
+ if (artifact.type !== TYPE) {
38
+ const clonedArtifact = cloneDeep(artifact);
39
+ clonedArtifact.type = TYPE;
40
+ props.onChange(clonedArtifact);
41
+ }
42
+
43
+ ArtifactService.getArtifactNames(TYPE, this.props.account.name).then(
44
+ (names) => {
45
+ this.setState({
46
+ names,
47
+ namesLoading: false,
48
+ });
49
+ },
50
+ () => {
51
+ this.setState({
52
+ names: [],
53
+ namesLoading: false,
54
+ versionsLoading: false,
55
+ versions: [],
56
+ });
57
+ },
58
+ );
59
+ }
60
+
61
+ public componentDidMount() {
62
+ const { artifact } = this.props;
63
+ if (artifact.name) {
64
+ this.getChartVersionOptions(artifact.name);
65
+ }
66
+ }
67
+
68
+ public componentDidUpdate(prevProps: IArtifactEditorProps) {
69
+ if (this.props.account.name !== prevProps.account.name) {
70
+ ArtifactService.getArtifactNames(TYPE, this.props.account.name).then(
71
+ (names) => {
72
+ this.setState({
73
+ names,
74
+ namesLoading: false,
75
+ versions: [],
76
+ });
77
+ },
78
+ () => {
79
+ this.setState({
80
+ names: [],
81
+ namesLoading: false,
82
+ versions: [],
83
+ });
84
+ },
85
+ );
86
+ }
87
+ }
88
+
89
+ public render() {
90
+ const { artifact } = this.props;
91
+ const nameOptions = this.state.names.map((name) => ({ value: name, label: name }));
92
+
93
+ return (
94
+ <>
95
+ <StageConfigField label="Name">
96
+ {!this.state.namesLoading && (
97
+ <TetheredSelect
98
+ options={nameOptions}
99
+ value={artifact.name || ''}
100
+ onChange={(e: Option) => {
101
+ this.onChange(e, 'name');
102
+ this.getChartVersionOptions(e.value.toString());
103
+ }}
104
+ clearable={false}
105
+ />
106
+ )}
107
+ {this.state.namesLoading && <Spinner />}
108
+ </StageConfigField>
109
+ <StageConfigField label="Version">
110
+ {!this.state.versionsLoading && (
111
+ <TetheredCreatable
112
+ options={this.state.versions}
113
+ value={artifact.version || ''}
114
+ onChange={(e: Option) => {
115
+ this.onChange(e, 'version');
116
+ }}
117
+ clearable={false}
118
+ />
119
+ )}
120
+ {this.state.versionsLoading && <Spinner />}
121
+ </StageConfigField>
122
+ </>
123
+ );
124
+ }
125
+
126
+ private onChange = (e: Option, field: keyof IArtifact) => {
127
+ const clone = cloneDeep(this.props.artifact);
128
+ (clone[field] as any) = e.value.toString();
129
+ this.props.onChange(clone);
130
+ };
131
+
132
+ private getChartVersionOptions(chartName: string) {
133
+ const { artifact, account } = this.props;
134
+ this.setState({ versionsLoading: true });
135
+ ArtifactService.getArtifactVersions(TYPE, account.name, chartName).then((versions: string[]) => {
136
+ // if the version doesn't match SEMVER we assume that it's a regular expression or SpEL expression
137
+ // and add it to the list of valid versions
138
+ if (artifact.version && !this.SEMVER.test(artifact.version)) {
139
+ versions = versions.concat(artifact.version);
140
+ }
141
+ this.setState({
142
+ versions: versions.map((v) => ({ label: v, value: v })),
143
+ versionsLoading: false,
144
+ });
145
+ });
146
+ }
147
+ }
148
+
149
+ export const HelmImageMatch: IArtifactKindConfig = {
150
+ label: 'Helm',
151
+ typePattern: ArtifactTypePatterns.HELM_IMAGE,
152
+ type: TYPE,
153
+ isDefault: false,
154
+ isMatch: true,
155
+ description: 'A helm chart to be deployed',
156
+ key: 'helm',
157
+ editCmp: HelmImageEditor,
158
+ };
159
+
160
+ export const HelmImageDefault: IArtifactKindConfig = {
161
+ label: 'Helm',
162
+ typePattern: ArtifactTypePatterns.HELM_IMAGE,
163
+ type: TYPE,
164
+ isDefault: true,
165
+ isMatch: false,
166
+ description: 'A helm chart to be deployed',
167
+ key: 'default.helm',
168
+ editCmp: HelmImageEditor,
169
+ };
@@ -7,6 +7,7 @@ import { GcsDefault, GcsMatch } from './gcs/GcsArtifactEditor';
7
7
  import { GithubDefault, GithubMatch } from './github/GithubArtifactEditor';
8
8
  import { GitlabDefault, GitlabMatch } from './gitlab/GitlabArtifactEditor';
9
9
  import { GitRepoDefault, GitRepoMatch } from './gitrepo/GitRepoArtifactEditor';
10
+ import { HelmImageDefault, HelmImageMatch } from './helm-image/HelmImageArtifactEditor';
10
11
  import { HelmDefault, HelmMatch } from './helm/HelmArtifactEditor';
11
12
  import { HttpDefault, HttpMatch } from './http/HttpArtifactEditor';
12
13
  import { IvyDefault, IvyMatch } from './ivy/IvyArtifactEditor';
@@ -35,6 +36,8 @@ export const artifactKindConfigs: IArtifactKindConfig[] = [
35
36
  GitlabDefault,
36
37
  HelmMatch,
37
38
  HelmDefault,
39
+ HelmImageMatch,
40
+ HelmImageDefault,
38
41
  HttpMatch,
39
42
  HttpDefault,
40
43
  IvyMatch,
@@ -1,5 +1,5 @@
1
1
  import type { FormikErrors } from 'formik';
2
- import { flatten, isNumber, values } from 'lodash';
2
+ import { flatten, isEmpty, isNumber, values } from 'lodash';
3
3
  import { $log, $q } from 'ngimport';
4
4
  import type { Subscription } from 'rxjs';
5
5
  import { Subject } from 'rxjs';
@@ -195,7 +195,7 @@ export class PipelineConfigValidator {
195
195
  if (pipeline.strategy && !pipeline.stages.some((stage) => stage.type === 'deploy')) {
196
196
  messages.push('To be able to create new server groups, a custom strategy should contain a Deploy stage.');
197
197
  }
198
- if ((pipeline.expectedArtifacts || []).some((a) => !a.matchArtifact || (a.matchArtifact as any) === {})) {
198
+ if ((pipeline.expectedArtifacts || []).some((a) => !a.matchArtifact || isEmpty(a.matchArtifact))) {
199
199
  messages.push('Every expected artifact must specify an artifact to match against.');
200
200
  }
201
201
  return messages;
@@ -89,9 +89,44 @@ export class ExecutionGroups extends React.Component<IExecutionGroupsProps, IExe
89
89
  const { groups = [], container, showingDetails } = this.state;
90
90
  const hasGroups = groups.length > 0;
91
91
  const className = `row pipelines executions ${showingDetails ? 'showing-details' : ''}`;
92
- const allGroups = (groups || [])
92
+
93
+ // Check if there are any duplicate execution IDs before applying deduplication
94
+ const allExecutionIds = groups.flatMap((group) => group.executions.map((e) => e.id));
95
+ const uniqueIds = new Set(allExecutionIds);
96
+ const hasDuplicates = allExecutionIds.length !== uniqueIds.size;
97
+
98
+ let processedGroups;
99
+
100
+ if (hasDuplicates) {
101
+ // Only apply deduplication if there are actually duplicate executions
102
+ const processedExecutionIds = new Set<string>();
103
+ const deduplicatedGroups = groups.map((group) => ({
104
+ ...group,
105
+ executions: [...group.executions],
106
+ runningExecutions: [...group.runningExecutions],
107
+ }));
108
+
109
+ deduplicatedGroups.forEach((group) => {
110
+ group.executions = group.executions.filter((execution) => {
111
+ if (processedExecutionIds.has(execution.id)) return false;
112
+ processedExecutionIds.add(execution.id);
113
+ return true;
114
+ });
115
+
116
+ group.runningExecutions = group.runningExecutions.filter((execution) =>
117
+ group.executions.some((e) => e.id === execution.id),
118
+ );
119
+ });
120
+
121
+ processedGroups = deduplicatedGroups.filter((group) => group.executions.length > 0);
122
+ } else {
123
+ // No duplicates, use the original groups
124
+ processedGroups = groups;
125
+ }
126
+
127
+ const allGroups = (processedGroups || [])
93
128
  .filter((g: IExecutionGroup) => g?.config?.migrationStatus === 'Started')
94
- .concat(groups.filter((g) => g?.config?.migrationStatus !== 'Started'));
129
+ .concat(processedGroups.filter((g) => g?.config?.migrationStatus !== 'Started'));
95
130
 
96
131
  const executionGroups = allGroups.map((group: IExecutionGroup) => (
97
132
  <ExecutionGroup parent={container} key={group.heading} group={group} application={this.props.application} />
@@ -255,9 +255,27 @@ export class ExecutionFilterService {
255
255
  private static groupExecutions(filteredExecutions: IExecution[], application: Application): IExecutionGroup[] {
256
256
  const groups: IExecutionGroup[] = [];
257
257
  let executions: IExecution[] = [];
258
- forOwn(groupBy(filteredExecutions, 'name'), (groupedExecutions) => {
259
- executions = executions.concat(groupedExecutions.sort((a, b) => this.executionSorter(a, b)));
260
- });
258
+
259
+ // Check if there are any duplicate execution IDs before applying deduplication
260
+ const executionIds = filteredExecutions.map((e) => e.id);
261
+ const uniqueIds = new Set(executionIds);
262
+ const hasDuplicates = executionIds.length !== uniqueIds.size;
263
+
264
+ if (hasDuplicates) {
265
+ // Only apply deduplication if there are actually duplicate executions
266
+ const processedExecutionIds = new Set<string>();
267
+
268
+ forOwn(groupBy(filteredExecutions, 'name'), (groupedExecutions) => {
269
+ const uniqueExecutions = groupedExecutions.filter((execution) => !processedExecutionIds.has(execution.id));
270
+ uniqueExecutions.forEach((execution) => processedExecutionIds.add(execution.id));
271
+ executions = executions.concat(uniqueExecutions.sort((a, b) => this.executionSorter(a, b)));
272
+ });
273
+ } else {
274
+ // No duplicates, use the original logic
275
+ forOwn(groupBy(filteredExecutions, 'name'), (groupedExecutions) => {
276
+ executions = executions.concat(groupedExecutions.sort((a, b) => this.executionSorter(a, b)));
277
+ });
278
+ }
261
279
 
262
280
  executions.forEach((execution: IExecution) => {
263
281
  const config: IPipeline = application.pipelineConfigs.data.find(
@@ -273,6 +291,11 @@ export class ExecutionFilterService {
273
291
  if (sortFilter.groupBy === 'name') {
274
292
  const executionGroups = groupBy(executions, 'name');
275
293
  forOwn(executionGroups, (groupExecutions, key) => {
294
+ // Skip empty groups
295
+ if (groupExecutions.length === 0) {
296
+ return;
297
+ }
298
+
276
299
  const matchId = (pipelineConfig: IPipeline) => pipelineConfig.id === groupExecutions[0].pipelineConfigId;
277
300
  const config = application.pipelineConfigs.data.concat(application.strategyConfigs?.data ?? []).find(matchId);
278
301
  groupExecutions.sort((a, b) => this.executionSorter(a, b));
@@ -291,6 +314,11 @@ export class ExecutionFilterService {
291
314
  if (sortFilter.groupBy === 'timeBoundary') {
292
315
  const grouped = this.groupByTimeBoundary(executions);
293
316
  forOwn(grouped, (groupExecutions: IExecution[], key) => {
317
+ // Skip empty groups
318
+ if (groupExecutions.length === 0) {
319
+ return;
320
+ }
321
+
294
322
  groupExecutions.sort((a, b) => this.executionSorter(a, b));
295
323
  groups.push({
296
324
  heading: key,
@@ -333,6 +361,19 @@ export class ExecutionFilterService {
333
361
 
334
362
  private static executionsAreDifferent(oldGroup: IExecutionGroup, newGroup: IExecutionGroup): boolean {
335
363
  let changeDetected = false;
364
+
365
+ const uniqueOldExecutions: IExecution[] = [];
366
+ const oldExecutionIds = new Set<string>();
367
+
368
+ oldGroup.executions.forEach((execution) => {
369
+ if (!oldExecutionIds.has(execution.id)) {
370
+ uniqueOldExecutions.push(execution);
371
+ oldExecutionIds.add(execution.id);
372
+ }
373
+ });
374
+
375
+ oldGroup.executions = uniqueOldExecutions;
376
+
336
377
  oldGroup.executions.forEach((execution) => {
337
378
  const newExecution = newGroup.executions.find((g) => g.id === execution.id);
338
379
  if (!newExecution) {
@@ -345,14 +386,18 @@ export class ExecutionFilterService {
345
386
  }
346
387
  }
347
388
  });
389
+
390
+ const existingExecutionIds = new Set(oldGroup.executions.map((e) => e.id));
391
+
348
392
  newGroup.executions.forEach((execution) => {
349
- const oldExecution = oldGroup.executions.find((g) => g.id === execution.id);
350
- if (!oldExecution) {
393
+ if (!existingExecutionIds.has(execution.id)) {
351
394
  changeDetected = true;
352
395
  $log.debug('new execution found, adding', execution.id);
353
396
  oldGroup.executions.push(execution);
397
+ existingExecutionIds.add(execution.id);
354
398
  }
355
399
  });
400
+
356
401
  return changeDetected;
357
402
  }
358
403
 
@@ -10,10 +10,18 @@ interface IDetailsProps {
10
10
  interface IDetailsHeaderProps {
11
11
  icon: React.ReactNode;
12
12
  name: string;
13
+ notifications?: React.ReactNode;
14
+ actions?: React.ReactNode;
15
+ }
16
+
17
+ interface IDetailsContentProps {
18
+ loading: boolean;
19
+ children: React.ReactNode;
13
20
  }
14
21
 
15
22
  interface IDetailsSFCWithExtras extends React.SFC<IDetailsProps> {
16
23
  Header: React.SFC<IDetailsHeaderProps>;
24
+ Content: React.FunctionComponent<IDetailsContentProps>;
17
25
  }
18
26
 
19
27
  const CloseButton = (
@@ -31,8 +39,12 @@ const DetailsHeader: React.SFC<IDetailsHeaderProps> = (props) => (
31
39
  {CloseButton}
32
40
  <div className="header-text horizontal middle">
33
41
  {props.icon}
34
- <h3 className="horizontal middle space-between flex-1">{props.name}</h3>
42
+ <h3 className="horizontal middle space-between flex-1">
43
+ {props.name}
44
+ {props.notifications && props.notifications}
45
+ </h3>
35
46
  </div>
47
+ {props.actions && <div className="actions">{props.actions}</div>}
36
48
  <div>{props.children}</div>
37
49
  </div>
38
50
  );
@@ -49,6 +61,11 @@ const Details: IDetailsSFCWithExtras = (props) => (
49
61
  <div className="details-panel">{props.loading ? loading : props.children}</div>
50
62
  );
51
63
 
64
+ const DetailsContent = ({ loading, children }: IDetailsContentProps) => (
65
+ <div className="content">{loading ? loading : children}</div>
66
+ );
67
+
52
68
  Details.Header = DetailsHeader;
69
+ Details.Content = DetailsContent;
53
70
 
54
71
  export { Details };
@@ -27,5 +27,5 @@ interface IFormikFormImplProps<T> extends IFormikFormProps<T> {
27
27
  * }} />
28
28
  * ```
29
29
  */
30
- const FormikFormImpl = <T extends any>({ render, formik }: IFormikFormImplProps<T>) => render(formik);
30
+ const FormikFormImpl = <T,>({ render, formik }: IFormikFormImplProps<T>) => render(formik);
31
31
  export const FormikForm: React.ComponentType<IFormikFormProps<any>> = connect(FormikFormImpl);