From 7afbe41fa5e5f346e91552553145deabf9260b5c Mon Sep 17 00:00:00 2001 From: Justin Reynolds Date: Fri, 1 Sep 2017 13:36:33 -0700 Subject: [PATCH] feat(core/pipeline): Support grouping stages that have a 'group' property --- .../core/src/delivery/delivery.module.js | 4 +- .../core/src/delivery/delivery.states.ts | 5 +- .../details/executionDetails.component.ts | 5 +- .../details/executionDetails.controller.js | 194 ------------- .../executionDetails.controller.spec.js | 65 ----- .../executionDetails.controller.spec.ts | 58 ++++ .../details/executionDetails.controller.ts | 258 ++++++++++++++++++ .../executionGroup/execution/Execution.tsx | 41 +-- .../execution/ExecutionMarker.tsx | 5 +- .../execution/executionMarker.less | 1 + .../filter/executionFilter.service.ts | 4 +- .../src/delivery/service/execution.service.ts | 66 ++++- .../service/executions.transformer.service.ts | 163 +++++++++-- .../modules/core/src/domain/IExecution.ts | 5 +- .../core/src/domain/IExecutionStage.ts | 3 + app/scripts/modules/core/src/domain/IStage.ts | 9 +- .../src/domain/IStageOrTriggerTypeConfig.ts | 2 +- .../core/src/domain/IStageTypeConfig.ts | 5 +- .../orchestratedItem.transformer.ts | 6 +- .../pipeline/config/graph/PipelineGraph.tsx | 2 +- .../config/graph/PipelineGraphNode.tsx | 142 ++++++---- .../pipeline/config/graph/pipelineGraph.less | 12 +- .../config/graph/pipelineGraph.service.ts | 1 + .../config/stages/core/ExecutionBarLabel.tsx | 15 +- .../stages/group/GroupExecutionLabel.tsx | 46 ++++ .../stages/group/GroupExecutionPopover.tsx | 77 ++++++ .../config/stages/group/GroupMarkerIcon.tsx | 14 + .../config/stages/group/groupStage.html | 2 + .../config/stages/group/groupStage.less | 67 +++++ .../config/stages/group/groupStage.module.ts | 8 + .../config/stages/group/groupStage.ts | 28 ++ .../stages/group/waitExecutionDetails.html | 24 ++ .../config/stages/travis/travisStage.ts | 4 +- .../config/triggers/git/git.trigger.ts | 4 +- .../config/triggers/pubsub/pubsub.trigger.ts | 4 +- .../triggers/travis/travisTrigger.module.ts | 4 +- .../core/src/pipeline/pipelines.module.js | 2 + .../src/presentation/HoverablePopover.tsx | 6 +- .../src/presentation/less/imports/colors.less | 1 + webpack.common.js | 2 +- 40 files changed, 948 insertions(+), 416 deletions(-) delete mode 100644 app/scripts/modules/core/src/delivery/details/executionDetails.controller.js delete mode 100644 app/scripts/modules/core/src/delivery/details/executionDetails.controller.spec.js create mode 100644 app/scripts/modules/core/src/delivery/details/executionDetails.controller.spec.ts create mode 100644 app/scripts/modules/core/src/delivery/details/executionDetails.controller.ts create mode 100644 app/scripts/modules/core/src/pipeline/config/stages/group/GroupExecutionLabel.tsx create mode 100644 app/scripts/modules/core/src/pipeline/config/stages/group/GroupExecutionPopover.tsx create mode 100644 app/scripts/modules/core/src/pipeline/config/stages/group/GroupMarkerIcon.tsx create mode 100644 app/scripts/modules/core/src/pipeline/config/stages/group/groupStage.html create mode 100644 app/scripts/modules/core/src/pipeline/config/stages/group/groupStage.less create mode 100644 app/scripts/modules/core/src/pipeline/config/stages/group/groupStage.module.ts create mode 100644 app/scripts/modules/core/src/pipeline/config/stages/group/groupStage.ts create mode 100644 app/scripts/modules/core/src/pipeline/config/stages/group/waitExecutionDetails.html diff --git a/app/scripts/modules/core/src/delivery/delivery.module.js b/app/scripts/modules/core/src/delivery/delivery.module.js index 945d3122a2f..5bba34bd6ca 100644 --- a/app/scripts/modules/core/src/delivery/delivery.module.js +++ b/app/scripts/modules/core/src/delivery/delivery.module.js @@ -4,6 +4,7 @@ const angular = require('angular'); import { DELIVERY_STATES } from './delivery.states'; import { EXECUTION_DETAILS_COMPONENT } from './details/executionDetails.component'; +import { EXECUTION_DETAILS_CONTROLLER } from './details/executionDetails.controller'; import { BUILD_DISPLAY_NAME_FILTER } from './executionBuild/buildDisplayName.filter'; import { EXECUTION_BUILD_NUMBER_COMPONENT } from './executionBuild/executionBuildNumber.component'; import { EXECUTION_COMPONENT } from './executionGroup/execution/execution.component'; @@ -15,8 +16,7 @@ import { CORE_DELIVERY_DETAILS_SINGLEEXECUTIONDETAILS } from './details/singleEx module.exports = angular.module('spinnaker.delivery', [ - - require('./details/executionDetails.controller.js'), + EXECUTION_DETAILS_CONTROLLER, CORE_DELIVERY_DETAILS_SINGLEEXECUTIONDETAILS, EXECUTION_COMPONENT, EXECUTION_GROUPS_COMPONENT, diff --git a/app/scripts/modules/core/src/delivery/delivery.states.ts b/app/scripts/modules/core/src/delivery/delivery.states.ts index dd2267d8289..1747cd010c7 100644 --- a/app/scripts/modules/core/src/delivery/delivery.states.ts +++ b/app/scripts/modules/core/src/delivery/delivery.states.ts @@ -33,7 +33,7 @@ module(DELIVERY_STATES, [ // replacing the URL const executionDetails: INestedState = { name: 'execution', - url: '/:executionId?refId&stage&step&details&stageId', + url: '/:executionId?refId&stage&subStage&step&details&stageId', params: { stage: { value: '0', @@ -41,9 +41,6 @@ module(DELIVERY_STATES, [ step: { value: '0', }, - refId: { - value: null, - } }, data: { pageTitleDetails: { diff --git a/app/scripts/modules/core/src/delivery/details/executionDetails.component.ts b/app/scripts/modules/core/src/delivery/details/executionDetails.component.ts index 39cdef09764..9cd17bf0ab6 100644 --- a/app/scripts/modules/core/src/delivery/details/executionDetails.component.ts +++ b/app/scripts/modules/core/src/delivery/details/executionDetails.component.ts @@ -1,5 +1,7 @@ import { IComponentOptions, module } from 'angular'; +import { ExecutionDetailsController } from './executionDetails.controller'; + import './executionDetails.less'; export class ExecutionDetailsComponent implements IComponentOptions { @@ -8,7 +10,8 @@ export class ExecutionDetailsComponent implements IComponentOptions { application: '<', standalone: '<' }; - public controller = 'executionDetails as ctrl'; + public controller = ExecutionDetailsController; + public controllerAs = 'ctrl'; public templateUrl: string = require('./executionDetails.html'); } diff --git a/app/scripts/modules/core/src/delivery/details/executionDetails.controller.js b/app/scripts/modules/core/src/delivery/details/executionDetails.controller.js deleted file mode 100644 index c4d280daed1..00000000000 --- a/app/scripts/modules/core/src/delivery/details/executionDetails.controller.js +++ /dev/null @@ -1,194 +0,0 @@ -'use strict'; - -import { EXECUTION_FILTER_SERVICE } from 'core/delivery/filter/executionFilter.service'; -import { PIPELINE_CONFIG_PROVIDER } from 'core/pipeline/config/pipelineConfigProvider'; - -const angular = require('angular'); - -module.exports = angular.module('spinnaker.executionDetails.controller', [ - require('@uirouter/angularjs').default, - PIPELINE_CONFIG_PROVIDER, - EXECUTION_FILTER_SERVICE -]) - .controller('executionDetails', function($scope, $stateParams, $state, pipelineConfig, executionFilterService) { - var controller = this; - - function getStageParams(stageId) { - const summaries = (controller.execution.stageSummaries || []); - const stageIndex = summaries.findIndex(s => (s.stages || []).some(s2 => s2.id === stageId)); - if (stageIndex !== -1) { - const stepIndex = (summaries[stageIndex].stages || []).findIndex(s => s.id === stageId); - if (stepIndex !== -1) { - return { - stage: stageIndex, - step: stepIndex, - stageId: null, - }; - } - } - return null; - } - - function getCurrentStage() { - if ($stateParams.stageId) { - const params = getStageParams($stateParams.stageId); - if (params) { - $state.go('.', params, {replace: true}); - return params.stage; - } - } - if ($stateParams.refId) { - let stages = controller.execution.stageSummaries || []; - let currentStageIndex = _.findIndex(stages, { refId: $stateParams.refId }); - if (currentStageIndex !== -1) { - $state.go('.', { - refId: null, - stage: currentStageIndex, - }); - return parseInt(currentStageIndex); - } else { - $state.go('.', { - refId: null, - }); - } - } - return parseInt($stateParams.stage); - } - - function getCurrentStep() { - return parseInt($stateParams.step); - } - - controller.close = function() { - $state.go('^'); - }; - - controller.toggleDetails = function(index) { - var newStepDetails = getCurrentStep() === index ? null : index; - if (newStepDetails !== null) { - $state.go('.', { - stage: getCurrentStage(), - step: newStepDetails, - }); - } - }; - - controller.isStageCurrent = function(index) { - return index === getCurrentStage(); - }; - - controller.isStepCurrent = function(index) { - return index === getCurrentStep(); - }; - - controller.closeDetails = function() { - $state.go('.', { step: null }); - }; - - this.getStageSummary = () => { - const currentStage = getCurrentStage(); - const stages = controller.execution.stageSummaries || []; - return stages.length > currentStage ? stages[currentStage] : null; - }; - - const getDetailsSourceUrl = () => { - if ($stateParams.step !== undefined) { - const stageSummary = this.getStageSummary(); - if (stageSummary) { - const step = stageSummary.stages[getCurrentStep()] || stageSummary.masterStage; - const stageConfig = pipelineConfig.getStageConfig(step); - if (stageConfig && stageConfig.executionDetailsUrl) { - if (stageConfig.executionConfigSections) { - $scope.configSections = stageConfig.executionConfigSections; - } else { - if (stageConfig.executionDetailsUrl !== this.executionDetailsUrl) { - $scope.configSections = []; // assume the stage's execution details controller will set it - } - } - return stageConfig.executionDetailsUrl; - } - return require('./defaultExecutionDetails.html'); - } - } - return null; - }; - - const getSummarySourceUrl = () => { - if ($stateParams.stage !== undefined) { - const stageSummary = this.getStageSummary(); - if (stageSummary) { - var stageConfig = pipelineConfig.getStageConfig(stageSummary); - if (stageConfig && stageConfig.executionSummaryUrl) { - return stageConfig.executionSummaryUrl; - } - } - } - return require('../../pipeline/config/stages/core/executionSummary.html'); - }; - - this.updateStage = (stageSummary) => { - if (stageSummary) { - $scope.stageSummary = stageSummary; - $scope.stage = stageSummary.stages[getCurrentStep()] || stageSummary.masterStage; - } - }; - - this.setSourceUrls = () => { - this.summarySourceUrl = getSummarySourceUrl(); - this.detailsSourceUrl = getDetailsSourceUrl(); - this.updateStage(this.getStageSummary()); - }; - - this.$onInit = () => { - this.setSourceUrls(); - this.standalone = this.standalone || false; - - // This is pretty dirty but executionDetails has its dirty tentacles - // all over the place. This makes the conversion of the execution directive - // to a component safe until we tackle converting all the controllers - // TODO: Convert all the execution details controllers to ES6 controllers and remove references to $scope - $scope.standalone = this.standalone; - $scope.application = this.application; - $scope.execution = this.execution; - - // Since stages and tasks can get updated without the reference to the execution changing, subscribe to the execution updated stream here too - this.groupsUpdatedSubscription = executionFilterService.groupsUpdatedStream.subscribe(() => $scope.$evalAsync(() => this.updateStage(this.getStageSummary()))); - }; - - this.$onChanges = () => { - $scope.standalone = this.standalone; - $scope.application = this.application; - $scope.execution = this.execution; - this.updateStage(this.getStageSummary()); - }; - - this.$onDestroy = () => { - this.groupsUpdatedSubscription.unsubscribe(); - }; - - $scope.$on('$stateChangeSuccess', () => this.setSourceUrls()); - - controller.getStepLabel = function(stage) { - var stageConfig = pipelineConfig.getStageConfig(stage); - if (stageConfig && stageConfig.executionStepLabelUrl) { - return stageConfig.executionStepLabelUrl; - } else { - return require('../../pipeline/config/stages/core/stepLabel.html'); - } - }; - - controller.isRestartable = function(stage) { - var stageConfig = pipelineConfig.getStageConfig(stage); - if (!stageConfig || stage.isRestarting === true) { - return false; - } - - const allowRestart = controller.application.attributes.enableRestartRunningExecutions || false; - if (controller.execution.isRunning && !allowRestart) { - return false; - } - - return stageConfig.restartable || false; - }; - - }); diff --git a/app/scripts/modules/core/src/delivery/details/executionDetails.controller.spec.js b/app/scripts/modules/core/src/delivery/details/executionDetails.controller.spec.js deleted file mode 100644 index edacf1ba4b7..00000000000 --- a/app/scripts/modules/core/src/delivery/details/executionDetails.controller.spec.js +++ /dev/null @@ -1,65 +0,0 @@ -'use strict'; - -describe('Controller: ExecutionDetails', function () { - - beforeEach( - window.module( - require('./executionDetails.controller'), - function ($provide) { - $provide.service('pipelineConfig', function () { - return { - getStageConfig: function (stage) { - return stage; - } - }; - }); - } - ) - ); - - beforeEach(window.inject(function ($controller, $rootScope, pipelineConfig) { - this.$scope = $rootScope.$new(); - this.$controller = $controller; - this.pipelineConfig = pipelineConfig; - })); - - describe('isRestartable', function() { - beforeEach(function() { - this.controller = this.$controller('executionDetails', { - $scope: this.$scope, - }); - this.controller.execution = { - isRunning: false - }; - this.controller.application = { - attributes: { - enableRestartRunningExecutions: false - } - }; - }); - - it('returns false when no stage config', function() { - expect(this.controller.isRestartable()).toBe(false); - }); - - it('returns false when stage is not restartable', function() { - expect(this.controller.isRestartable({restartable: false})).toBe(false); - }); - - it('returns false when stage is already restarting', function() { - expect(this.controller.isRestartable({restartable: true, isRestarting: true})).toBe(false); - }); - - it('returns true when stage is restartable', function() { - expect(this.controller.isRestartable({restartable: true})).toBe(true); - }); - - it('returns true when stage is running, is restartable and enableRestartRunningExecutions=true', function() { - this.controller.execution.isRunning = true; - this.controller.application.attributes.enableRestartRunningExecutions = true; - this.$scope.$digest(); - expect(this.controller.isRestartable({restartable: true})).toBe(true); - }); - }); - -}); diff --git a/app/scripts/modules/core/src/delivery/details/executionDetails.controller.spec.ts b/app/scripts/modules/core/src/delivery/details/executionDetails.controller.spec.ts new file mode 100644 index 00000000000..b95ac9de13f --- /dev/null +++ b/app/scripts/modules/core/src/delivery/details/executionDetails.controller.spec.ts @@ -0,0 +1,58 @@ +import { IControllerService, IRootScopeService, IScope, auto, mock } from 'angular'; +import { EXECUTION_DETAILS_CONTROLLER, ExecutionDetailsController } from './executionDetails.controller'; + +import { IStage } from 'core/domain'; + +describe('Controller: ExecutionDetails', () => { + let $scope: IScope; + let controller: ExecutionDetailsController; + let pipelineConfig: any; + + beforeEach(mock.module( + EXECUTION_DETAILS_CONTROLLER, + ($provide: auto.IProvideService) => { + $provide.service('pipelineConfig', () => { + return { getStageConfig: (stage: IStage) => stage }; + }); + } + )); + + beforeEach(mock.inject(($controller: IControllerService, + $rootScope: IRootScopeService, + _pipelineConfig_: any) => { + $scope = $rootScope.$new(); + controller = $controller(ExecutionDetailsController, { + $scope: $scope + }); + controller.execution = { isRunning: false } as any; + controller.application = { attributes: { enableRestartRunningExecutions: false } } as any; + + pipelineConfig = _pipelineConfig_; + })); + + describe('isRestartable', () => { + it('returns false when no stage config', () => { + expect(controller.isRestartable()).toBe(false); + }); + + it('returns false when stage is not restartable', () => { + expect(controller.isRestartable({restartable: false} as any)).toBe(false); + }); + + it('returns false when stage is already restarting', () => { + expect(controller.isRestartable({restartable: true, isRestarting: true} as any)).toBe(false); + }); + + it('returns true when stage is restartable', () => { + expect(controller.isRestartable({restartable: true} as any)).toBe(true); + }); + + it('returns true when stage is running, is restartable and enableRestartRunningExecutions=true', () => { + controller.execution.isRunning = true; + controller.application.attributes.enableRestartRunningExecutions = true; + $scope.$digest(); + expect(controller.isRestartable({restartable: true} as any)).toBe(true); + }); + }); + +}); diff --git a/app/scripts/modules/core/src/delivery/details/executionDetails.controller.ts b/app/scripts/modules/core/src/delivery/details/executionDetails.controller.ts new file mode 100644 index 00000000000..2d53f0f78ec --- /dev/null +++ b/app/scripts/modules/core/src/delivery/details/executionDetails.controller.ts @@ -0,0 +1,258 @@ +import { IScope, module } from 'angular'; +import { StateParams, StateService } from '@uirouter/angularjs'; +import { Subscription } from 'rxjs'; + +import { Application } from 'core/application'; +import { IExecution, IExecutionStageSummary, IStage } from 'core/domain'; +import { EXECUTION_FILTER_SERVICE, ExecutionFilterService } from 'core/delivery/filter/executionFilter.service'; +import { PIPELINE_CONFIG_PROVIDER, PipelineConfigProvider } from 'core/pipeline/config/pipelineConfigProvider'; + +export interface IExecutionStateParams { + stage?: number; + subStage?: number; + step?: number; + stageId?: string; + refId?: string; +} + +export class ExecutionDetailsController { + private summarySourceUrl: string; + private detailsSourceUrl: string; + private groupsUpdatedSubscription: Subscription; + + private standalone: boolean; + public execution: IExecution; + public application: Application; + + constructor(private $scope: IScope, + private $stateParams: StateParams, + private $state: StateService, + private pipelineConfig: PipelineConfigProvider, + private executionFilterService: ExecutionFilterService) { + 'ngInject'; + + this.$scope.$on('$stateChangeSuccess', () => this.setSourceUrls()); + } + + private getStageParamsFromStageId(stageId: string): IExecutionStateParams { + const summaries = this.execution.stageSummaries || []; + + let stage, subStage, step; + summaries.some((summary, index) => { + let stepIndex = (summary.stages || []).findIndex(s2 => s2.id === stageId); + if (stepIndex !== -1) { + step = stepIndex; + stage = index; + return true; + } + if (summary.type === 'group' && summary.groupStages) { + summary.groupStages.some((groupStage, subIndex) => { + stepIndex = (groupStage.stages || []).findIndex((gs) => gs.id === stageId); + if (stepIndex !== -1) { + step = stepIndex; + stage = index; + subStage = subIndex; + return true; + } + return false; + }); + } + return false; + }); + + if (stage) { + return { stage, subStage, step, stageId: null }; + } + return null; + } + + private getStageParamsFromRefId(refId: string): IExecutionStateParams { + const summaries = this.execution.stageSummaries || []; + + let stage, subStage; + + const stageIndex = summaries.findIndex((summary) => summary.refId === refId); + if (stageIndex !== -1) { + return { stage: stageIndex, refId: null }; + } + + summaries.some((summary, index) => { + if (summary.type === 'group' && summary.groupStages) { + const subStageIndex = summary.groupStages.findIndex(s2 => s2.refId === refId); + if (subStageIndex !== -1) { + stage = index; + subStage = subStageIndex; + return true; + } + } + return false; + }); + if (stage && subStage !== undefined) { + return { stage, subStage, refId: null }; + } + + return { refId: null }; + } + + private getCurrentStage(): { stage: number, subStage: number } { + if (this.$stateParams.stageId) { + const params = this.getStageParamsFromStageId(this.$stateParams.stageId); + if (params) { + this.$state.go('.', params, { location: 'replace' }); + return { stage: params.stage, subStage: params.subStage }; + } + } + if (this.$stateParams.refId) { + const params = this.getStageParamsFromRefId(this.$stateParams.refId); + if (params) { + this.$state.go('.', params, { location: 'replace' }); + return { stage: params.stage, subStage: params.subStage }; + } + } + + return { stage: parseInt(this.$stateParams.stage, 10), subStage: parseInt(this.$stateParams.subStage, 10) }; + } + + private getCurrentStep() { + return parseInt(this.$stateParams.step, 10); + } + + public close() { + this.$state.go('^'); + } + + public toggleDetails(index: number): void { + const newStepDetails = this.getCurrentStep() === index ? null : index; + if (newStepDetails !== null) { + const { stage, subStage } = this.getCurrentStage(); + this.$state.go('.', { + stage, + subStage, + step: newStepDetails, + }); + } + } + + public isStepCurrent(index: number): boolean { + return index === this.getCurrentStep(); + } + + public closeDetails(): void { + this.$state.go('.', { step: null }); + } + + private getStageSummary() { + const { stage, subStage } = this.getCurrentStage(); + const stages = this.execution.stageSummaries || []; + let currentStage = null; + if (stage !== undefined) { + currentStage = stages[stage]; + if (currentStage && subStage !== undefined && currentStage.groupStages) { + currentStage = currentStage.groupStages[subStage]; + } + } + return currentStage; + } + + private getDetailsSourceUrl(): string { + if (this.$stateParams.step !== undefined) { + const stageSummary = this.getStageSummary(); + if (stageSummary) { + const step = stageSummary.stages[this.getCurrentStep()] || stageSummary.masterStage; + const stageConfig = this.pipelineConfig.getStageConfig(step); + if (stageConfig && stageConfig.executionDetailsUrl) { + if (stageConfig.executionConfigSections) { + this.$scope.configSections = stageConfig.executionConfigSections; + } + return stageConfig.executionDetailsUrl; + } + return require('./defaultExecutionDetails.html'); + } + } + return null; + } + + private getSummarySourceUrl(): string { + if (this.$stateParams.stage !== undefined) { + const stageSummary = this.getStageSummary(); + if (stageSummary) { + const stageConfig = this.pipelineConfig.getStageConfig(stageSummary); + if (stageConfig && stageConfig.executionSummaryUrl) { + return stageConfig.executionSummaryUrl; + } + } + } + return require('../../pipeline/config/stages/core/executionSummary.html'); + } + + public updateStage(stageSummary: IExecutionStageSummary) { + if (stageSummary) { + this.$scope.stageSummary = stageSummary; + this.$scope.stage = stageSummary.stages[this.getCurrentStep()] || stageSummary.masterStage; + } + } + + public setSourceUrls() { + this.summarySourceUrl = this.getSummarySourceUrl(); + this.detailsSourceUrl = this.getDetailsSourceUrl(); + this.updateStage(this.getStageSummary()); + } + + public $onInit() { + this.setSourceUrls(); + this.standalone = this.standalone || false; + + // This is pretty dirty but executionDetails has its dirty tentacles + // all over the place. This makes the conversion of the execution directive + // to a component safe until we tackle converting all the controllers + // TODO: Convert all the execution details controllers to ES6 controllers and remove references to $scope + this.$scope.standalone = this.standalone; + this.$scope.application = this.application; + this.$scope.execution = this.execution; + + // Since stages and tasks can get updated without the reference to the execution changing, subscribe to the execution updated stream here too + this.groupsUpdatedSubscription = this.executionFilterService.groupsUpdatedStream.subscribe(() => this.$scope.$evalAsync(() => this.updateStage(this.getStageSummary()))); + } + + public $onChanges(): void { + this.$scope.standalone = this.standalone; + this.$scope.application = this.application; + this.$scope.execution = this.execution; + this.updateStage(this.getStageSummary()); + } + + public $onDestroy(): void { + this.groupsUpdatedSubscription.unsubscribe(); + } + + public getStepLabel(stage: IStage): string { + const stageConfig = this.pipelineConfig.getStageConfig(stage); + if (stageConfig && stageConfig.executionStepLabelUrl) { + return stageConfig.executionStepLabelUrl; + } else { + return require('../../pipeline/config/stages/core/stepLabel.html'); + } + } + + public isRestartable(stage?: IStage): boolean { + const stageConfig = this.pipelineConfig.getStageConfig(stage); + if (!stageConfig || stage.isRestarting === true) { + return false; + } + + const allowRestart = this.application.attributes.enableRestartRunningExecutions || false; + if (this.execution.isRunning && !allowRestart) { + return false; + } + + return stageConfig.restartable || false; + } +} + +export const EXECUTION_DETAILS_CONTROLLER = 'spinnaker.core.delivery.details.executionDetails.controller'; +module(EXECUTION_DETAILS_CONTROLLER, [ + require('@uirouter/angularjs').default, + PIPELINE_CONFIG_PROVIDER, + EXECUTION_FILTER_SERVICE +]) + .controller('executionDetails', ExecutionDetailsController); diff --git a/app/scripts/modules/core/src/delivery/executionGroup/execution/Execution.tsx b/app/scripts/modules/core/src/delivery/executionGroup/execution/Execution.tsx index 1311f061783..c06ff7822b5 100644 --- a/app/scripts/modules/core/src/delivery/executionGroup/execution/Execution.tsx +++ b/app/scripts/modules/core/src/delivery/executionGroup/execution/Execution.tsx @@ -8,8 +8,7 @@ import * as classNames from 'classnames'; import { Application } from 'core/application/application.model'; import { IExecution , IRestartDetails } from 'core/domain'; -import { IExecutionViewState } from 'core/pipeline/config/graph/pipelineGraph.service'; -import { IPipelineGraphNode } from 'core/pipeline/config/graph/pipelineGraph.service'; +import { IExecutionViewState, IPipelineGraphNode } from 'core/pipeline/config/graph/pipelineGraph.service'; import { IScheduler } from 'core/scheduler/scheduler.factory'; import { OrchestratedItemRunningTime } from './OrchestratedItemRunningTime'; import { SETTINGS } from 'core/config/settings'; @@ -60,10 +59,11 @@ export class Execution extends React.Component constructor(props: IExecutionProps) { super(props); const { execution } = this.props; - const { executionFilterModel } = ReactInjector; + const { $stateParams, executionFilterModel } = ReactInjector; const initialViewState = { - activeStageId: Number(ReactInjector.$stateParams.stage), + activeStageId: Number($stateParams.stage), + activeSubStageId: Number($stateParams.subStage), executionId: execution.id, canTriggerPipelineManually: false, canConfigure: false, @@ -85,6 +85,7 @@ export class Execution extends React.Component const { $stateParams } = ReactInjector; const newViewState = clone(this.state.viewState); newViewState.activeStageId = Number($stateParams.stage); + newViewState.activeSubStageId = Number($stateParams.subStage); newViewState.executionId = $stateParams.executionId; this.setState({ viewState: newViewState, @@ -102,31 +103,9 @@ export class Execution extends React.Component return this.state.showingDetails && Number(ReactInjector.$stateParams.stage) === stageIndex; } - public toggleDetails(stageIndex?: number): void { - const { $state } = ReactInjector; - if (this.props.execution.id === $state.params.executionId && $state.current.name.includes('.execution') && stageIndex === undefined) { - $state.go('^'); - return; - } - const index = stageIndex || 0; - const stageSummary = this.props.execution.stageSummaries[index] || { firstActiveStage: 0 }; - const params = { - executionId: this.props.execution.id, - stage: index, - step: stageSummary.firstActiveStage - }; - - if ($state.includes('**.execution', params)) { - if (!this.props.standalone) { - $state.go('^'); - } - } else { - if ($state.current.name.endsWith('.execution') || this.props.standalone) { - $state.go('.', params); - } else { - $state.go('.execution', params); - } - } + public toggleDetails(stageIndex?: number, subIndex?: number): void { + const { executionService } = ReactInjector; + executionService.toggleDetails(this.props.execution, stageIndex, subIndex); } public getUrl(): string { @@ -225,8 +204,8 @@ export class Execution extends React.Component this.stateChangeSuccessSubscription.unsubscribe(); } - private handleNodeClick(node: IPipelineGraphNode): void { - this.toggleDetails(node.index); + private handleNodeClick(node: IPipelineGraphNode, subIndex: number): void { + this.toggleDetails(node.index, subIndex); } private handleSourceNoStagesClick(): void { diff --git a/app/scripts/modules/core/src/delivery/executionGroup/execution/ExecutionMarker.tsx b/app/scripts/modules/core/src/delivery/executionGroup/execution/ExecutionMarker.tsx index e41d4ee58b3..6f5b10e27ca 100644 --- a/app/scripts/modules/core/src/delivery/executionGroup/execution/ExecutionMarker.tsx +++ b/app/scripts/modules/core/src/delivery/executionGroup/execution/ExecutionMarker.tsx @@ -56,11 +56,12 @@ export class ExecutionMarker extends React.Component(execution, ['stageSummaries', index]); + if (stageSummary && stageSummary.type === 'group') { + if (subIndex === undefined) { + // Disallow clicking on a group itself + return; + } + stageSummary = get(stageSummary, ['groupStages', subIndex]); + } + stageSummary = stageSummary || { firstActiveStage: 0 } as IExecutionStageSummary; + + const params = { + executionId: execution.id, + stage: index, + subStage: subIndex, + step: stageSummary.firstActiveStage, + } as any; + + // Can't show details of a grouped stage + if (subIndex === undefined && stageSummary.type === 'group') { + params.stage = null; + params.step = null; + return; + } + + if (this.$state.includes('**.execution', params)) { + if (!standalone) { + this.$state.go('^'); + } + } else { + if (this.$state.current.name.endsWith('.execution') || standalone) { + this.$state.go('.', params); + } else { + this.$state.go('.execution', params); + } + } + } + // these fields are never displayed in the UI, so don't retain references to them, as they consume a lot of memory // on very large deployments private removeInstances(stage: IExecutionStage): void { @@ -232,7 +280,7 @@ export class ExecutionService { if (!executions || !executions.length) { return []; } - executions.forEach((execution) => this.executionsTransformer.transformExecution({}, execution)); + executions.forEach((execution) => this.executionsTransformer.transformExecution({} as Application, execution)); return executions.sort((a, b) => b.startTime - (a.startTime || Date.now())); }); } @@ -411,7 +459,7 @@ module(EXECUTION_SERVICE, [ EXECUTIONS_TRANSFORMER_SERVICE, PIPELINE_CONFIG_PROVIDER, API_SERVICE -]).factory('executionService', ($http: IHttpService, $q: IQService, $timeout: ITimeoutService, API: Api, executionFilterModel: any, executionsTransformer: any, pipelineConfig: any) => - new ExecutionService($http, $q, $timeout, API, executionFilterModel, executionsTransformer, pipelineConfig)); +]).factory('executionService', ($http: IHttpService, $q: IQService, $state: StateService, $timeout: ITimeoutService, API: Api, executionFilterModel: any, executionsTransformer: any, pipelineConfig: any) => + new ExecutionService($http, $q, $state, $timeout, API, executionFilterModel, executionsTransformer, pipelineConfig)); DebugWindow.addInjectable('executionService'); diff --git a/app/scripts/modules/core/src/delivery/service/executions.transformer.service.ts b/app/scripts/modules/core/src/delivery/service/executions.transformer.service.ts index f2962f4325c..d88d8decde2 100644 --- a/app/scripts/modules/core/src/delivery/service/executions.transformer.service.ts +++ b/app/scripts/modules/core/src/delivery/service/executions.transformer.service.ts @@ -1,5 +1,6 @@ import { module } from 'angular'; +import { duration } from 'moment'; import { find, findLast, flattenDeep, get, has, maxBy, uniq, sortBy } from 'lodash'; import { Application } from 'core/application'; @@ -41,6 +42,11 @@ export class ExecutionsTransformerService { } private flattenStages(stages: IExecutionStage[], stage: IExecutionStage | IExecutionStageSummary): IExecutionStage[] { + const stageSummary = stage as IExecutionStageSummary; + if (stageSummary.groupStages) { + stageSummary.groupStages.forEach((s) => this.flattenStages(stages, s)); + return stages; + } if (stage.before && stage.before.length) { stage.before.sort((a, b) => this.siblingStageSorter(a, b)); stage.before.forEach((beforeStage) => this.flattenStages(stages, beforeStage)); @@ -85,6 +91,16 @@ export class ExecutionsTransformerService { } } + private getCurrentStage(stages: T[]) { + const lastStage = stages[stages.length - 1]; + const lastNotStartedStage = findLast(stages, (childStage) => childStage.hasNotStarted); + const lastFailedStage = findLast(stages, (childStage) => childStage.isFailed); + const lastRunningStage = findLast(stages, (childStage) => childStage.isRunning); + const lastCanceledStage = findLast(stages, (childStage) => childStage.isCanceled); + const lastStoppedStage = findLast(stages, (childStage) => childStage.isStopped); + return lastRunningStage || lastFailedStage || lastStoppedStage || lastCanceledStage || lastNotStartedStage || lastStage; + } + private transformStage(stage: IExecutionStageSummary): void { const stages = this.flattenAndFilter(stage); if (!stages.length) { @@ -92,16 +108,9 @@ export class ExecutionsTransformerService { } if ((stage as IExecutionStageSummary).masterStage) { - const lastStage = stages[stages.length - 1]; this.setMasterStageStartTime(stages, stage as IExecutionStageSummary); - const lastNotStartedStage = findLast(stages, (childStage) => childStage.hasNotStarted); - const lastFailedStage = findLast(stages, (childStage) => childStage.isFailed); - const lastRunningStage = findLast(stages, (childStage) => childStage.isRunning); - const lastCanceledStage = findLast(stages, (childStage) => childStage.isCanceled); - const lastStoppedStage = findLast(stages, (childStage) => childStage.isStopped); - const currentStage = lastRunningStage || lastFailedStage || lastStoppedStage || lastCanceledStage || lastNotStartedStage || lastStage; - + const currentStage = this.getCurrentStage(stages); stage.status = currentStage.status; // if a stage is running, ignore the endTime of the parent stage @@ -163,8 +172,9 @@ export class ExecutionsTransformerService { execution.stageWidth = 100 / execution.stageSummaries.length + '%'; } - private styleStage(stage: IExecutionStageSummary): void { - const stageConfig = this.pipelineConfig.getStageConfig(stage); + private styleStage(stage: IExecutionStageSummary, styleStage?: IExecutionStageSummary): void { + styleStage = styleStage || stage; + const stageConfig = this.pipelineConfig.getStageConfig(styleStage); if (stageConfig) { stage.labelComponent = stageConfig.executionLabelComponent || ExecutionBarLabel; stage.markerIcon = stageConfig.markerIcon || ExecutionMarkerIcon; @@ -212,6 +222,10 @@ export class ExecutionsTransformerService { this.transformStage(summary); this.styleStage(summary); OrchestratedItemTransformer.defineProperties(summary); + + if (summary.type === 'group') { + summary.groupStages.forEach((stage, i) => this.transformStageSummary(stage, i)); + } } private filterStages(summary: IExecutionStageSummary): void { @@ -246,7 +260,6 @@ export class ExecutionsTransformerService { this.pipelineConfig.getExecutionTransformers().forEach((transformer) => { transformer.transform(application, execution); }); - const stageSummaries: IExecutionStageSummary[] = []; execution.context = execution.context || {}; execution.stages.forEach((stage, index) => { @@ -259,21 +272,43 @@ export class ExecutionsTransformerService { } }); + // Handle synthetic stages execution.stages.forEach((stage) => { - const context = stage.context || {}; - const owner = stage.syntheticStageOwner; const parent = find(execution.stages, { id: stage.parentStageId }); if (parent) { - if (owner === 'STAGE_BEFORE') { + if (stage.syntheticStageOwner === 'STAGE_BEFORE') { parent.before.push(stage); } - if (owner === 'STAGE_AFTER') { + if (stage.syntheticStageOwner === 'STAGE_AFTER') { parent.after.push(stage); } } + const context = stage.context || {}; stage.cloudProvider = context.cloudProvider || context.cloudProviderType; }); + OrchestratedItemTransformer.defineProperties(execution); + this.processStageSummaries(execution); + } + + private calculateRunningTime(stage: IExecutionStageSummary): () => number { + return () => { + // Find the earliest startTime and latest endTime + stage.groupStages.forEach((subStage) => { + if (subStage.startTime && subStage.startTime < stage.startTime) { stage.startTime = subStage.startTime; } + if (subStage.endTime && subStage.endTime > stage.endTime) { stage.endTime = subStage.endTime; } + }); + + if (!stage.startTime) { + return null; + } + const normalizedNow: number = Math.max(Date.now(), stage.startTime); + return (stage.endTime || normalizedNow) - stage.startTime; + }; + } + + public processStageSummaries(execution: IExecution): void { + let stageSummaries: IExecutionStageSummary[] = []; execution.stages.forEach((stage) => { if (!stage.syntheticStageOwner && !this.hiddenStageTypes.includes(stage.type)) { // HACK: Orca sometimes (always?) incorrectly reports a parent stage as running when a child stage has stopped @@ -282,27 +317,103 @@ export class ExecutionsTransformerService { } const context = stage.context || {}; const summary: IExecutionStageSummary = { - name: stage.name, - id: stage.id, - startTime: stage.startTime, - endTime: stage.endTime, - masterStage: stage, - type: stage.type, - before: stage.before, after: stage.after, - status: stage.status, - comments: context.comments || null, + before: stage.before, cloudProvider: stage.cloudProvider, + comments: context.comments || null, + endTime: stage.endTime, + group: context.group, + id: stage.id, + index: undefined, + masterStage: stage, + name: stage.name, refId: stage.refId, requisiteStageRefIds: stage.requisiteStageRefIds && stage.requisiteStageRefIds[0] === '*' ? [] : stage.requisiteStageRefIds || [], stages: [], - index: undefined + startTime: stage.startTime, + status: stage.status, + type: stage.type, } as IExecutionStageSummary; stageSummaries.push(summary); } }); - OrchestratedItemTransformer.defineProperties(execution); + const idToGroupIdMap: { [key: string]: (number | string) } = {}; + stageSummaries = stageSummaries.reduce((groupedStages, stage) => { + + // Since everything should already be sorted, if the stage is not in a group, just push it on and continue + if (!stage.group) { + groupedStages.push(stage); + return groupedStages; + } + + // The stage is in a group + let groupedStage = groupedStages.find((s) => s.type === 'group' && s.name === stage.group); + if (!groupedStage) { + // Create a new grouped stage + groupedStage = { + activeStageType: undefined, + after: undefined, + before: stage.before, + cloudProvider: stage.cloudProvider, // what if the group has two different cloud providers? + comments: '', + endTime: stage.endTime, + groupStages: [], + id: stage.group, // TODO: Can't key off group name because a partial 'group' can be used multiple times... + index: undefined, + masterStage: undefined, + name: stage.group, + refId: stage.group, // TODO: Can't key off group name because a partial 'group' can be used multiple times... + requisiteStageRefIds: stage.requisiteStageRefIds && stage.requisiteStageRefIds[0] === '*' ? [] : stage.requisiteStageRefIds || [], // TODO: No idea what to do with refids... + stages: [], + startTime: stage.startTime, + status: undefined, + type: 'group', + } as IExecutionStageSummary; + groupedStages.push(groupedStage); + } + OrchestratedItemTransformer.defineProperties(stage); + + // Update the runningTimeInMs function to account for the group + Object.defineProperties(groupedStage, { + runningTime: { + get: () => duration(this.calculateRunningTime(groupedStage)()).humanize(), + configurable: true, + }, + runningTimeInMs: { + get: this.calculateRunningTime(groupedStage), + configurable: true, + } + }); + + idToGroupIdMap[stage.refId] = groupedStage.refId; + groupedStage.groupStages.push(stage); + return groupedStages; + }, [] as IExecutionStageSummary[]); + + stageSummaries.forEach((summary) => { + if (summary.type === 'group' && summary.groupStages.length > 1) { + const subComments: string[] = []; + // Find the earliest startTime and latest endTime + summary.groupStages.forEach((subStage) => { + if (subStage.comments) { subComments.push(subStage.comments); } + }); + + // Assuming the last stage in the group has the "output" stages + summary.after = summary.groupStages[summary.groupStages.length - 1].after; + + const currentStage = this.getCurrentStage(summary.groupStages); + summary.activeStageType = currentStage.type; + summary.status = currentStage.status; + this.styleStage(summary, currentStage); + + // Set the group comment as a concatenation of all the stage summary comments + summary.comments = subComments.join(', '); + } + + // Make sure the requisite ids that were pointing at stages within a group are now pointing at the group + summary.requisiteStageRefIds = uniq(summary.requisiteStageRefIds.map((id) => idToGroupIdMap[id] || id)); + }); stageSummaries.forEach((summary, index) => this.transformStageSummary(summary, index)); execution.stageSummaries = stageSummaries; diff --git a/app/scripts/modules/core/src/domain/IExecution.ts b/app/scripts/modules/core/src/domain/IExecution.ts index 80bec43110d..4917f257982 100644 --- a/app/scripts/modules/core/src/domain/IExecution.ts +++ b/app/scripts/modules/core/src/domain/IExecution.ts @@ -11,6 +11,7 @@ export interface IExecution extends IOrchestratedItem { context?: { [key: string]: any }; currentStages?: IExecutionStageSummary[]; deploymentTargets: string[]; + // expandedGroups?: {[groupId: string]: boolean}; graphStatusHash?: string; id: string; isComplete?: boolean; @@ -18,8 +19,8 @@ export interface IExecution extends IOrchestratedItem { name?: string; pipelineConfigId?: string; searchField?: string; - stageSummaries?: IExecutionStageSummary[]; - stageWidth?: string; + stageSummaries?: IExecutionStageSummary[]; // added by transformer + stageWidth?: string; // added by transformer stages: IExecutionStage[]; stringVal?: string; trigger: IExecutionTrigger; diff --git a/app/scripts/modules/core/src/domain/IExecutionStage.ts b/app/scripts/modules/core/src/domain/IExecutionStage.ts index 7a65e93b21a..2b60092e867 100644 --- a/app/scripts/modules/core/src/domain/IExecutionStage.ts +++ b/app/scripts/modules/core/src/domain/IExecutionStage.ts @@ -35,6 +35,7 @@ export interface IExecutionStageLabelComponentProps { } export interface IExecutionStageSummary extends IOrchestratedItem { + activeStageType?: string, after: IExecutionStage[]; before: IExecutionStage[]; cloudProvider: string; @@ -43,6 +44,8 @@ export interface IExecutionStageSummary extends IOrchestratedItem { endTime: number; extraLabelLines?: (stage: IExecutionStageSummary) => number; firstActiveStage?: number; + group?: string; + groupStages?: IExecutionStageSummary[]; id: string; inSuspendedExecutionWindow?: boolean; index: number; diff --git a/app/scripts/modules/core/src/domain/IStage.ts b/app/scripts/modules/core/src/domain/IStage.ts index 60536bfc821..96c49f4eeec 100644 --- a/app/scripts/modules/core/src/domain/IStage.ts +++ b/app/scripts/modules/core/src/domain/IStage.ts @@ -1,9 +1,10 @@ export interface IStage { + [k: string]: any; + alias?: string; + group?: string; + isNew?: boolean; name: string; - type: string; refId: string | number; // unfortunately, we kept this loose early on, so it's either a string or a number requisiteStageRefIds: (string | number)[]; - [k: string]: any; - isNew?: boolean; - alias?: string; + type: string; } diff --git a/app/scripts/modules/core/src/domain/IStageOrTriggerTypeConfig.ts b/app/scripts/modules/core/src/domain/IStageOrTriggerTypeConfig.ts index 55c4b5b50ec..cd2049e8242 100644 --- a/app/scripts/modules/core/src/domain/IStageOrTriggerTypeConfig.ts +++ b/app/scripts/modules/core/src/domain/IStageOrTriggerTypeConfig.ts @@ -7,6 +7,6 @@ export interface IStageOrTriggerTypeConfig { key: string; templateUrl: string; controller: string; - controllerAs: string; + controllerAs?: string; validators: IValidatorConfig[]; } diff --git a/app/scripts/modules/core/src/domain/IStageTypeConfig.ts b/app/scripts/modules/core/src/domain/IStageTypeConfig.ts index ef8e8a2c099..a52e7b93d3d 100644 --- a/app/scripts/modules/core/src/domain/IStageTypeConfig.ts +++ b/app/scripts/modules/core/src/domain/IStageTypeConfig.ts @@ -8,12 +8,14 @@ export interface IStageTypeConfig extends IStageOrTriggerTypeConfig { alias?: string; cloudProvider?: string; cloudProviders?: string[]; + configAccountExtractor?: any; configuration?: any; defaultTimeoutMs?: number; executionConfigSections?: string[]; - executionDetailsUrl: string; + executionDetailsUrl?: string; executionLabelComponent?: React.ComponentClass; executionStepLabelUrl?: string; + executionSummaryUrl?: string; extraLabelLines?: (stage: IStage) => number; markerIcon?: React.ComponentClass<{ stage: IExecutionStageSummary }>; nameToCheckInTest?: string; @@ -21,6 +23,7 @@ export interface IStageTypeConfig extends IStageOrTriggerTypeConfig { providesFor?: string[]; restartable?: boolean; stageFilter?: (stage: IStage) => boolean; + strategy?: boolean; synthetic?: boolean; useBaseProvider?: boolean; useCustomTooltip?: boolean; diff --git a/app/scripts/modules/core/src/orchestratedItem/orchestratedItem.transformer.ts b/app/scripts/modules/core/src/orchestratedItem/orchestratedItem.transformer.ts index d3cb576d070..778229a8c16 100644 --- a/app/scripts/modules/core/src/orchestratedItem/orchestratedItem.transformer.ts +++ b/app/scripts/modules/core/src/orchestratedItem/orchestratedItem.transformer.ts @@ -78,10 +78,12 @@ export class OrchestratedItemTransformer { } }, runningTime: { - get: () => duration(this.calculateRunningTime(item)()).humanize() + get: () => duration(this.calculateRunningTime(item)()).humanize(), + configurable: true, }, runningTimeInMs: { - get: this.calculateRunningTime(item) + get: this.calculateRunningTime(item), + configurable: true, } }); } diff --git a/app/scripts/modules/core/src/pipeline/config/graph/PipelineGraph.tsx b/app/scripts/modules/core/src/pipeline/config/graph/PipelineGraph.tsx index 7ed01237422..5cf63571e7c 100644 --- a/app/scripts/modules/core/src/pipeline/config/graph/PipelineGraph.tsx +++ b/app/scripts/modules/core/src/pipeline/config/graph/PipelineGraph.tsx @@ -20,7 +20,7 @@ import './pipelineGraph.less' export interface IPipelineGraphProps { execution?: IExecution; - onNodeClick: (node: IPipelineGraphNode) => void; + onNodeClick: (node: IPipelineGraphNode, subIndex?: number) => void; pipeline?: IPipeline; shouldValidate?: boolean; viewState: IExecutionViewState; diff --git a/app/scripts/modules/core/src/pipeline/config/graph/PipelineGraphNode.tsx b/app/scripts/modules/core/src/pipeline/config/graph/PipelineGraphNode.tsx index d64bacc7165..9eb33a72273 100644 --- a/app/scripts/modules/core/src/pipeline/config/graph/PipelineGraphNode.tsx +++ b/app/scripts/modules/core/src/pipeline/config/graph/PipelineGraphNode.tsx @@ -5,6 +5,8 @@ import * as classNames from 'classnames'; import autoBindMethods from 'class-autobind-decorator'; import { get } from 'lodash'; +import { IExecutionStageSummary } from 'core/domain'; +import { GroupExecutionPopover } from 'core/pipeline/config/stages/group/GroupExecutionPopover'; import { LabelComponent } from 'core/presentation'; import { Popover } from 'core/presentation/Popover'; @@ -15,7 +17,7 @@ export interface IPipelineGraphNodeProps { labelOffsetX: number; labelOffsetY: number; maxLabelWidth: number; - nodeClicked: (node: IPipelineGraphNode) => void; + nodeClicked: (node: IPipelineGraphNode | IExecutionStageSummary, subIndex?: number) => void; highlight: (node: IPipelineGraphNode, highlight: boolean) => void; nodeRadius: number; node: IPipelineGraphNode; @@ -23,15 +25,15 @@ export interface IPipelineGraphNodeProps { @autoBindMethods export class PipelineGraphNode extends React.Component { - private highlight() { + private highlight(): void { this.props.highlight(this.props.node, true); } - private removeHighlight() { + private removeHighlight(): void { this.props.highlight(this.props.node, false); } - private handleClick() { + private handleClick(): void { ReactGA.event({ category: `Pipeline Graph (${this.props.isExecution ? 'execution' : 'config'})`, action: `Node clicked` @@ -39,16 +41,29 @@ export class PipelineGraphNode extends React.Component this.props.nodeClicked(this.props.node); } + private subStageClicked(groupStage: IExecutionStageSummary, stage: IExecutionStageSummary): void { + ReactGA.event({ + category: `Pipeline Graph (${this.props.isExecution ? 'execution' : 'config'})`, + action: `Grouped stage clicked` + }); + this.props.nodeClicked(groupStage, stage.index); + } + public render() { const { labelOffsetX, labelOffsetY, maxLabelWidth, nodeRadius, node } = this.props; - const masterStageType = get(node, ['masterStage', 'type'], ''); + const isGroup = node.stage && node.stage.type === 'group'; + let stageType = get(node, ['masterStage', 'type'], undefined); + stageType = stageType || get(node, ['stage', 'activeStageType'], ''); const circleClassName = classNames( - 'clickable', - `stage-type-${masterStageType.toLowerCase()}`, + `stage-type-${stageType.toLowerCase()}`, 'execution-marker', `execution-marker-${(node.status || '').toLowerCase()}`, - { active: node.isActive } + 'graph-node', + { + active: node.isActive, + clickable: !isGroup + } ); const warningsPopover = ( @@ -62,65 +77,86 @@ export class PipelineGraphNode extends React.Component let GraphNode = ( - - - { (node.root || node.leaf) && !node.executionStage && ( - - )} - + { isGroup && ( + + )} + + { !isGroup && ( + + )} + + { (node.root || node.leaf) && !node.executionStage && !isGroup && ( + + )} + ); + // Only for pipeline if (node.hasWarnings) { GraphNode = {GraphNode}; } - const GraphLabel = ( + // Add the group popover to the circle if the node is representative of a group + // Only executions have a 'stage' property + if (node.stage && node.stage.type === 'group') { + GraphNode = {GraphNode}; + } + + // Render the label differently if there is a custom label component + let GraphLabel = node.labelComponent ? ( +
+ +
+ ) : ( + + ); + + // Add the group popover to the label if the node is representative of a group + if (node.stage && node.stage.type === 'group') { + GraphLabel = {GraphLabel} + } + + // Wrap all the label html in a foreignObject to make SVG happy + GraphLabel = ( - { node.labelComponent && ( -
- -
- )} - { !node.labelComponent && ( - - )} + {GraphLabel}
); return ( - + {GraphNode} {GraphLabel} diff --git a/app/scripts/modules/core/src/pipeline/config/graph/pipelineGraph.less b/app/scripts/modules/core/src/pipeline/config/graph/pipelineGraph.less index 7090fb34a61..2c86cff6e39 100644 --- a/app/scripts/modules/core/src/pipeline/config/graph/pipelineGraph.less +++ b/app/scripts/modules/core/src/pipeline/config/graph/pipelineGraph.less @@ -72,14 +72,14 @@ pipeline-graph, .pipeline-graph { display: inline-block; } &.active, &.highlighted { - circle, rect { + circle, rect, path.graph-node { transition: fill .2s; } &.has-status { - circle { + circle.graph-node, path.graph-node { fill-opacity: 1; stroke: @mid_grey; - stroke-width: 2px; + stroke-width: 1px; } } } @@ -137,6 +137,12 @@ pipeline-graph, .pipeline-graph { } } +.stage-group { + .label-component { + cursor: default; + } +} + .execution-graph { margin: 0 20px; padding: 10px 10px 20px 10px; diff --git a/app/scripts/modules/core/src/pipeline/config/graph/pipelineGraph.service.ts b/app/scripts/modules/core/src/pipeline/config/graph/pipelineGraph.service.ts index f991f4b8ff2..3f02756c6b4 100644 --- a/app/scripts/modules/core/src/pipeline/config/graph/pipelineGraph.service.ts +++ b/app/scripts/modules/core/src/pipeline/config/graph/pipelineGraph.service.ts @@ -2,6 +2,7 @@ import { IExecution, IExecutionStageSummary, IPipeline, IStage } from 'core/doma export interface IExecutionViewState { activeStageId: number; + activeSubStageId: number; canConfigure: boolean; canTriggerPipelineManually: boolean; executionId: string; diff --git a/app/scripts/modules/core/src/pipeline/config/stages/core/ExecutionBarLabel.tsx b/app/scripts/modules/core/src/pipeline/config/stages/core/ExecutionBarLabel.tsx index 8ff65cc0b4d..fcb9ee1bb46 100644 --- a/app/scripts/modules/core/src/pipeline/config/stages/core/ExecutionBarLabel.tsx +++ b/app/scripts/modules/core/src/pipeline/config/stages/core/ExecutionBarLabel.tsx @@ -4,6 +4,7 @@ import { OverlayTrigger, Tooltip } from 'react-bootstrap'; import { IExecutionStageLabelComponentProps } from 'core/domain'; import { ExecutionWindowActions } from 'core/pipeline/config/stages/executionWindows/ExecutionWindowActions'; import { HoverablePopover } from 'core/presentation/HoverablePopover'; +import { ReactInjector } from 'core/reactShims'; export interface IExecutionBarLabelProps extends IExecutionStageLabelComponentProps { tooltip?: JSX.Element; @@ -40,8 +41,20 @@ export class ExecutionBarLabel extends React.Component ); } + + let stageName = stage.name ? stage.name : stage.type; + const params = ReactInjector.$uiRouter.globals.params; + if (stage.type === 'group' && stage.groupStages && stage.index === Number(params.stage)) { + const subStageIndex = Number(params.subStage); + if (!Number.isNaN(subStageIndex)) { + const activeStage = stage.groupStages[subStageIndex]; + if (activeStage) { + stageName += `: ${activeStage.name}`; + } + } + } return ( - {stage.name ? stage.name : stage.type} + {stageName} ) } } diff --git a/app/scripts/modules/core/src/pipeline/config/stages/group/GroupExecutionLabel.tsx b/app/scripts/modules/core/src/pipeline/config/stages/group/GroupExecutionLabel.tsx new file mode 100644 index 00000000000..539ee5893aa --- /dev/null +++ b/app/scripts/modules/core/src/pipeline/config/stages/group/GroupExecutionLabel.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import autoBindMethods from 'class-autobind-decorator'; + +import { IExecutionStageSummary, IExecution } from 'core/domain'; +import { Application } from 'core/application/application.model'; +import { ExecutionBarLabel } from 'core/pipeline/config/stages/core/ExecutionBarLabel'; +import { ReactInjector } from 'core/reactShims'; + +import { GroupExecutionPopover } from './GroupExecutionPopover'; + +import './groupStage.less'; + +export interface IGroupExecutionLabelProps { + stage: IExecutionStageSummary; + execution: IExecution; + application: Application; + executionMarker: boolean; +} + +export interface IGroupedStageListItemProps { + execution: IExecution; + groupStage: IExecutionStageSummary; + stage: IExecutionStageSummary; +} + +@autoBindMethods +export class GroupExecutionLabel extends React.Component { + private subStageClicked(groupStage: IExecutionStageSummary, stage: IExecutionStageSummary): void { + const { executionService } = ReactInjector; + executionService.toggleDetails(this.props.execution, groupStage.index, stage.index); + } + + public render() { + const { stage } = this.props; + + if (!this.props.executionMarker) { + return (); + } + + return ( + + {this.props.children} + + ); + } +} diff --git a/app/scripts/modules/core/src/pipeline/config/stages/group/GroupExecutionPopover.tsx b/app/scripts/modules/core/src/pipeline/config/stages/group/GroupExecutionPopover.tsx new file mode 100644 index 00000000000..14ed09f4fa8 --- /dev/null +++ b/app/scripts/modules/core/src/pipeline/config/stages/group/GroupExecutionPopover.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import autoBindMethods from 'class-autobind-decorator'; + +import { IExecutionStageSummary } from 'core/domain'; +import { HoverablePopover } from 'core/presentation/HoverablePopover'; + +import './groupStage.less'; + +export interface IGroupExecutionPopoverProps { + stage: IExecutionStageSummary; + subStageClicked?: (groupStage: IExecutionStageSummary, stage: IExecutionStageSummary) => void; +} + +export interface IGroupedStageListItemProps { + stage: IExecutionStageSummary; + stageClicked?: (stage: IExecutionStageSummary) => void; +} + +@autoBindMethods +class GroupedStageListItem extends React.Component { + private onClick(): void { + if (this.props.stageClicked) { + this.props.stageClicked(this.props.stage) + } + } + + public render(): React.ReactElement { + const { stage } = this.props; + const markerClassName = [ + 'clickable', + 'stage-status', + 'execution-marker', + `stage-type-${stage.type.toLowerCase()}`, + `execution-marker-${stage.status.toLowerCase()}`, + stage.isRunning ? 'glowing' : '' + ].join(' '); + + return ( + +
  • +
    +
    + {stage.name} +
    +
  • +
    + ) + } +} + +@autoBindMethods +export class GroupExecutionPopover extends React.Component { + private subStageClicked(subStage: IExecutionStageSummary): void { + if (this.props.subStageClicked) { + this.props.subStageClicked(this.props.stage, subStage); + } + } + + public render() { + const { stage } = this.props; + + const template = ( +
    +
      +
    • {stage.name.toUpperCase()}
    • + {stage.groupStages.map((s) => )} +
    +
    + ); + + return ( + + {this.props.children} + + ); + } +} diff --git a/app/scripts/modules/core/src/pipeline/config/stages/group/GroupMarkerIcon.tsx b/app/scripts/modules/core/src/pipeline/config/stages/group/GroupMarkerIcon.tsx new file mode 100644 index 00000000000..8d477303bc1 --- /dev/null +++ b/app/scripts/modules/core/src/pipeline/config/stages/group/GroupMarkerIcon.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import {IExecutionMarkerIconProps} from '../core/ExecutionMarkerIcon'; + +export class GroupMarkerIcon extends React.Component { + public render() { + return ( +
    + + + +
    + ); + } +} diff --git a/app/scripts/modules/core/src/pipeline/config/stages/group/groupStage.html b/app/scripts/modules/core/src/pipeline/config/stages/group/groupStage.html new file mode 100644 index 00000000000..c93ae084853 --- /dev/null +++ b/app/scripts/modules/core/src/pipeline/config/stages/group/groupStage.html @@ -0,0 +1,2 @@ +
    +
    diff --git a/app/scripts/modules/core/src/pipeline/config/stages/group/groupStage.less b/app/scripts/modules/core/src/pipeline/config/stages/group/groupStage.less new file mode 100644 index 00000000000..734ce0ce689 --- /dev/null +++ b/app/scripts/modules/core/src/pipeline/config/stages/group/groupStage.less @@ -0,0 +1,67 @@ +@import (reference) "~core/presentation/less/imports/colors.less"; + +.group-execution-label-list { + .group-name { + color: @dark_blue_background_title; + border-bottom: 1px #7e94a4 solid; + margin-bottom: 7px; + } + + list-style-type: none; + padding: 3px 10px 0 10px; + >a, >a:hover, >a:visited { + text-decoration: none; + color: inherit; + } + li { + div { + display: flex; + padding: 3px; + } + font-size: 13px; + >:hover { + background-color: lighten(@dark_blue_background, 10%); + } + span+span { + padding-left: 20px; + } + .stage-status { + display: inline-block; + width: 13px; + height: 13px; + border-radius: 15px; + margin-right: 7px; + margin-top: 2px; + } + } + a:first-child { + >li { + border-top: none; + } + } +} + +.group-stages-list-popover { + background-color: @dark_blue_background !important; + .popover-content, &.inverse { + background-color: @dark_blue_background; + color: white; + padding: 5px; + } + + &.top > div.arrow:after { + border-top-color: @dark_blue_background; + } + + &.bottom > div.arrow:after { + border-bottom-color: @dark_blue_background; + } +} + +.marker-group-icon { + display: inline-block; + vertical-align: middle; + svg { + fill: var(--color-text-secondary); + } +} diff --git a/app/scripts/modules/core/src/pipeline/config/stages/group/groupStage.module.ts b/app/scripts/modules/core/src/pipeline/config/stages/group/groupStage.module.ts new file mode 100644 index 00000000000..62b99e777ec --- /dev/null +++ b/app/scripts/modules/core/src/pipeline/config/stages/group/groupStage.module.ts @@ -0,0 +1,8 @@ +import { module } from 'angular'; + +import { GROUP_STAGE } from './groupStage'; + +export const GROUP_STAGE_MODULE = 'spinnaker.core.pipeline.stage.group'; +module(GROUP_STAGE_MODULE, [ + GROUP_STAGE, +]); diff --git a/app/scripts/modules/core/src/pipeline/config/stages/group/groupStage.ts b/app/scripts/modules/core/src/pipeline/config/stages/group/groupStage.ts new file mode 100644 index 00000000000..222f5c25885 --- /dev/null +++ b/app/scripts/modules/core/src/pipeline/config/stages/group/groupStage.ts @@ -0,0 +1,28 @@ +import { IController, module } from 'angular'; + +import { GroupExecutionLabel } from './GroupExecutionLabel'; +import { GroupMarkerIcon } from './GroupMarkerIcon'; +import { PIPELINE_CONFIG_PROVIDER, PipelineConfigProvider } from 'core/pipeline/config/pipelineConfigProvider'; + +export const GROUP_STAGE = 'spinnaker.core.pipeline.stage.groupStage'; + +export class GroupStage implements IController {} + +module(GROUP_STAGE, [ + PIPELINE_CONFIG_PROVIDER +]) +.config((pipelineConfigProvider: PipelineConfigProvider) => { + pipelineConfigProvider.registerStage({ + controller: 'GroupStageCtrl', + description: 'A group of stages', + executionDetailsUrl: require('core/delivery/details/executionDetails.html'), + executionLabelComponent: GroupExecutionLabel, + markerIcon: GroupMarkerIcon, + key: 'group', + label: 'Group', + templateUrl: require('./groupStage.html'), + useCustomTooltip: true, + validators: [], + }); +}) +.controller('GroupStageCtrl', GroupStage); diff --git a/app/scripts/modules/core/src/pipeline/config/stages/group/waitExecutionDetails.html b/app/scripts/modules/core/src/pipeline/config/stages/group/waitExecutionDetails.html new file mode 100644 index 00000000000..c46da209a42 --- /dev/null +++ b/app/scripts/modules/core/src/pipeline/config/stages/group/waitExecutionDetails.html @@ -0,0 +1,24 @@ +
    + +
    + + + + +
    + +
    +
    + +
    +
    +
    diff --git a/app/scripts/modules/core/src/pipeline/config/stages/travis/travisStage.ts b/app/scripts/modules/core/src/pipeline/config/stages/travis/travisStage.ts index b5dcede4e27..5a44b5773cd 100644 --- a/app/scripts/modules/core/src/pipeline/config/stages/travis/travisStage.ts +++ b/app/scripts/modules/core/src/pipeline/config/stages/travis/travisStage.ts @@ -1,4 +1,4 @@ -import { PIPELINE_CONFIG_PROVIDER } from 'core/pipeline/config/pipelineConfigProvider'; +import { PIPELINE_CONFIG_PROVIDER, PipelineConfigProvider } from 'core/pipeline/config/pipelineConfigProvider'; import { IController, IScope, module } from 'angular'; import * as moment from 'moment'; @@ -145,7 +145,7 @@ export const TRAVIS_STAGE = 'spinnaker.core.pipeline.stage.travisStage'; module(TRAVIS_STAGE, [ IGOR_SERVICE, PIPELINE_CONFIG_PROVIDER -]).config((pipelineConfigProvider: any) => { +]).config((pipelineConfigProvider: PipelineConfigProvider) => { if (SETTINGS.feature.travis) { pipelineConfigProvider.registerStage({ diff --git a/app/scripts/modules/core/src/pipeline/config/triggers/git/git.trigger.ts b/app/scripts/modules/core/src/pipeline/config/triggers/git/git.trigger.ts index f6ef9df2404..de156d8c354 100644 --- a/app/scripts/modules/core/src/pipeline/config/triggers/git/git.trigger.ts +++ b/app/scripts/modules/core/src/pipeline/config/triggers/git/git.trigger.ts @@ -3,7 +3,7 @@ import { has, trim } from 'lodash'; import { SETTINGS } from 'core/config/settings'; import { IGitTrigger } from 'core/domain/ITrigger'; -import { PIPELINE_CONFIG_PROVIDER } from 'core/pipeline/config/pipelineConfigProvider'; +import { PIPELINE_CONFIG_PROVIDER, PipelineConfigProvider } from 'core/pipeline/config/pipelineConfigProvider'; import { SERVICE_ACCOUNT_SERVICE, ServiceAccountService } from 'core/serviceAccount/serviceAccount.service'; class GitTriggerController implements IController { @@ -65,7 +65,7 @@ export const GIT_TRIGGER = 'spinnaker.core.pipeline.trigger.git'; module(GIT_TRIGGER, [ SERVICE_ACCOUNT_SERVICE, PIPELINE_CONFIG_PROVIDER, -]).config((pipelineConfigProvider: any) => { +]).config((pipelineConfigProvider: PipelineConfigProvider) => { pipelineConfigProvider.registerTrigger({ label: 'Git', description: 'Executes the pipeline on a git push', diff --git a/app/scripts/modules/core/src/pipeline/config/triggers/pubsub/pubsub.trigger.ts b/app/scripts/modules/core/src/pipeline/config/triggers/pubsub/pubsub.trigger.ts index a4888347135..c4fe3d2f085 100644 --- a/app/scripts/modules/core/src/pipeline/config/triggers/pubsub/pubsub.trigger.ts +++ b/app/scripts/modules/core/src/pipeline/config/triggers/pubsub/pubsub.trigger.ts @@ -1,6 +1,6 @@ import { IController, module } from 'angular'; -import { PIPELINE_CONFIG_PROVIDER } from 'core/pipeline/config/pipelineConfigProvider'; +import { PIPELINE_CONFIG_PROVIDER, PipelineConfigProvider } from 'core/pipeline/config/pipelineConfigProvider'; import { IPubsubTrigger } from '@spinnaker/core'; class PubsubTriggerController implements IController { @@ -14,7 +14,7 @@ class PubsubTriggerController implements IController { export const PUBSUB_TRIGGER = 'spinnaker.core.pipeline.trigger.pubsub'; module(PUBSUB_TRIGGER, [ PIPELINE_CONFIG_PROVIDER, -]).config((pipelineConfigProvider: any) => { +]).config((pipelineConfigProvider: PipelineConfigProvider) => { pipelineConfigProvider.registerTrigger({ label: 'Pub/Sub', description: 'Executes the pipeline when a pubsub message is received', diff --git a/app/scripts/modules/core/src/pipeline/config/triggers/travis/travisTrigger.module.ts b/app/scripts/modules/core/src/pipeline/config/triggers/travis/travisTrigger.module.ts index 78d2459fd87..f7e831eb0c0 100644 --- a/app/scripts/modules/core/src/pipeline/config/triggers/travis/travisTrigger.module.ts +++ b/app/scripts/modules/core/src/pipeline/config/triggers/travis/travisTrigger.module.ts @@ -1,7 +1,7 @@ import { IController, IQService, IScope, module } from 'angular'; import { IGOR_SERVICE, IgorService, BuildServiceType } from 'core/ci/igor.service'; -import { PIPELINE_CONFIG_PROVIDER } from 'core/pipeline/config/pipelineConfigProvider'; +import { PIPELINE_CONFIG_PROVIDER, PipelineConfigProvider } from 'core/pipeline/config/pipelineConfigProvider'; import { SERVICE_ACCOUNT_SERVICE, ServiceAccountService } from 'core/serviceAccount/serviceAccount.service'; import { IBuildTrigger } from 'core/domain/ITrigger'; import { TRAVIS_TRIGGER_OPTIONS_COMPONENT } from './travisTriggerOptions.component'; @@ -87,7 +87,7 @@ module(TRAVIS_TRIGGER, [ IGOR_SERVICE, SERVICE_ACCOUNT_SERVICE, PIPELINE_CONFIG_PROVIDER, -]).config((pipelineConfigProvider: any) => { +]).config((pipelineConfigProvider: PipelineConfigProvider) => { pipelineConfigProvider.registerTrigger({ label: 'Travis', description: 'Listens to a Travis job', diff --git a/app/scripts/modules/core/src/pipeline/pipelines.module.js b/app/scripts/modules/core/src/pipeline/pipelines.module.js index e051393b411..9cfbe42781a 100644 --- a/app/scripts/modules/core/src/pipeline/pipelines.module.js +++ b/app/scripts/modules/core/src/pipeline/pipelines.module.js @@ -3,6 +3,7 @@ const angular = require('angular'); import { COPY_STAGE_MODAL_CONTROLLER } from './config/copyStage/copyStage.modal.controller'; +import { GROUP_STAGE_MODULE } from './config/stages/group/groupStage.module'; import { TRAVIS_STAGE_MODULE } from './config/stages/travis/travisStage.module'; import { UNMATCHED_STAGE_TYPE_STAGE } from './config/stages/unmatchedStageTypeStage/unmatchedStageTypeStage'; import { WEBHOOK_STAGE_MODULE } from './config/stages/webhook/webhookStage.module'; @@ -14,6 +15,7 @@ module.exports = angular.module('spinnaker.core.pipeline', [ 'ui.sortable', require('./config/pipelineConfig.module'), COPY_STAGE_MODAL_CONTROLLER, + GROUP_STAGE_MODULE, TRAVIS_STAGE_MODULE, WEBHOOK_STAGE_MODULE, UNMATCHED_STAGE_TYPE_STAGE, diff --git a/app/scripts/modules/core/src/presentation/HoverablePopover.tsx b/app/scripts/modules/core/src/presentation/HoverablePopover.tsx index b71f69abe1d..1ef31c5b6f8 100644 --- a/app/scripts/modules/core/src/presentation/HoverablePopover.tsx +++ b/app/scripts/modules/core/src/presentation/HoverablePopover.tsx @@ -142,7 +142,7 @@ export class HoverablePopover extends React.Component + {this.props.children} - + ); } } diff --git a/app/scripts/modules/core/src/presentation/less/imports/colors.less b/app/scripts/modules/core/src/presentation/less/imports/colors.less index 6e5a80a6791..de53fae878f 100644 --- a/app/scripts/modules/core/src/presentation/less/imports/colors.less +++ b/app/scripts/modules/core/src/presentation/less/imports/colors.less @@ -15,6 +15,7 @@ @succeeded_job: #E6FFE6; @failed_job: #FFE6E6; @default_link_hover: #39546a; +@dark_blue_background_title: #c1d4db; @secondary_panel_grey: #f1f1f1; diff --git a/webpack.common.js b/webpack.common.js index 39b00eb5228..f441c5e551d 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -39,7 +39,7 @@ function configure(IS_TEST) { moduleExtensions: ['-loader'] }, resolve: { - extensions: ['.json', '.js', '.jsx', '.ts', '.tsx', '.css', '.less', '.html'], + extensions: ['.json', '.ts', '.tsx', '.js', '.jsx', '.css', '.less', '.html'], modules: [ NODE_MODULE_PATH, path.join(__dirname, 'app', 'scripts', 'modules'),